---
title: Scope play-type params to compatible data-view columns
type: task
status: planned
description: >-
  Strip mutually-exclusive play-type filters (e.g. pass:true on a rush-yards column) server-side
  before query build, with column play scope derived from pid_columns, so data-view columns stop
  silently returning empty results. Fixes existing and new shared URLs at the data layer; no client
  or react-table changes.
base_uri: user:task/league/scope-play-type-params-to-compatible-data-view-columns.md
created_at: '2026-06-17T04:54:43.830Z'
entity_id: c3b358a3-1a69-4cbc-a818-084375e539ad
observations:
  - ████████████████████████████████████████████████
owner_identity_uri: █████████████████████████
priority: Medium
public_read: true
relations:
  - relates [[██████████████████████████████-████-███████]]
  - relates [[████████████████████-████-████-██████-██████████████-███-██████-████████]]
  - relates [[user:task/league/refactor-data-view-from-plays-measure-first-rate-types.md]]
  - precedes [[user:task/league/surface-incompatible-data-view-column-params-client-ux.md]]
tags:
  - user:tag/league-xo-football.md
updated_at: '2026-06-17T05:35:15.912Z'
visibility_analyzed_at: '2026-11-05T23:50:20.732Z'
---

# Implementation Plan: Scope play-type params to compatible data-view columns

## Overview

- Prevent data-view columns from silently returning empty/degenerate results when a play-type filter is applied that is mutually exclusive with the column's stat (the `pass: true` on `player_rush_yards_from_plays` class of bug).
- Deliver this by stripping the contradictory play-type boolean server-side, inside the single per-column param-finalization funnel (`process_item_params`), with the column's play scope derived from the `pid_columns` already present on its definition. Recurse into the nested `output_column_params` so the rate-type denominator path is covered too.
- Impact: end users loading or sharing data views (including already-shared `/u/{hash}` links) get correct, non-empty data for columns that today render blank because of a self-contradictory per-column filter. No change to any valid configuration's results.
- Ordering: self-contained, no upstream dependency. Bounded to a single task entity (not orchestrator-scale). Precedes the client-UX follow-up `user:task/league/surface-incompatible-data-view-column-params-client-ux.md` (picker suppression, chip cleanup, self-contradiction notice). Relates to `███████████████████████████████████████████` (diagnosis surfacing), to the Completed notice infrastructure `████████████████████████████████████████████████████████████████████████`, and to `user:task/league/refactor-data-view-from-plays-measure-first-rate-types.md` (shares the `get-data-view-results.mjs` param-finalization region).

## Background

- Root cause confirmed via a debug session on `https://xo.football/u/1cd5ab54e78dba202eef0bca26c14934`: the `player_rush_yards_from_plays` column carried `pass: true` (plus `qb_scramble: true`). `pass` and `rush` are mutually exclusive play-level booleans, so `SUM(rush_yds)` over `pass = true` plays is empty. Verified against `nfl_plays` 2019-2025: scrambles are coded `rush = true`, never `pass = true`, so `pass = true AND qb_scramble = true` has been an empty set every season. This is not a code or data regression; the view was authored fresh and the query faithfully summed over zero rows.
- The contradictory param reaches the column because every from-plays field advertises the entire play-param set: `column_params: { ...nfl_plays_column_params }` in `app/core/data-views-fields/player-stats-from-plays-table-fields.js` (lines 8, 20, 28) and `team-stats-from-plays-table-fields.js` (lines 16-19). The UI param picker and the `react-table` replace/duplicate carry-forward both trust that list, so `pass` is offered and carried onto rush columns.
- `react-table` is first-party but consumed as a pinned GitHub dependency (`mistakia/react-table#878e30e3...`, `package.json:173`), installed into `node_modules` — not in-tree. Editing its replace/duplicate logic would require a separate-repo change plus a dependency bump. This plan needs no `react-table` change.
- Server-side, all four per-column param arrays (`where`, `columns`, `prefix_columns`, `sort`) are finalized through one funnel: `process_item_params` (`libs-server/get-data-view-results.mjs:461`, calling `process_params_with_backwards_compatibility` at `:453`), applied as a batch at `:1536-1539`. This batch runs before every query-build consumer — the where-clause loop (`:1366`), the FROM-table optimization (`get_from_table_config` `:1546`, which reads the already-processed sort), the output aggregator dispatch (`:1846`), and the sort-clause loop (`:1989`). Stripping inside `process_item_params` therefore covers all of those paths from one place. `column_definition` (which carries `pid_columns`) is resolvable inside `process_item_params` via `data_views_column_definitions[item.column_id]` (registry imported at `:15`).
- One path is NOT covered by the funnel: the nested `output_column_params` / `rate_type_column_params` object, whose play-level keys are copied into the rate-type denominator CTE by `libs-shared/get-rate-type-denominator-params.mjs:69-78`. The strip must recurse into this nested object. (The specific debugged view has no `output_column_params`, so the funnel strip alone fixes it; the nested strip is for full bug-class coverage of rate-type columns.)
- `pid_columns` is exposed as a property on the from-plays definition (`libs-server/data-views-column-definitions/player-stats-from-plays-column-definitions.mjs:215`) and cleanly encodes play scope. Distinct live values (grepped across `libs-server/data-views-column-definitions/*.mjs`): `['passer_pid']` (30), `['target_pid']` (21), `['ball_carrier_pid']` (18), `['ball_carrier_pid','target_pid']` (8), `['qb_pid']` (2), `['player_fuml_pid']` (2), and three other multi-pid arrays. `rec_pid` has zero live uses.
- Existing validation gap: `app/core/data-views/data-view-notices.mjs` only cross-checks table-level `where` filters against columns; it never inspects a single column's own params, which is why this view produced no warning. Closing that gap is the client-UX follow-up's job (the notice engine ships rule-by-rule, so it is a small add there).

### Assumptions

- `pid_columns` is present on every from-plays stat definition and exposed on the returned definition object — checked: column-definitions:215; grep of distinct values above.
- `process_item_params` (`:1536-1539`) is the single funnel through which `where`/`columns`/`prefix_columns`/`sort` params are finalized, and it runs before all query-build consumers — checked: ordering 1536-1539 precedes the where loop, `get_from_table_config` (:1546), output aggregator (:1846), and sort clauses (:1989).
- The nested `output_column_params`/`rate_type_column_params` object is the only param path the funnel does not reach — checked: `get-rate-type-denominator-params.mjs:69-78` reads it directly; no other nested play-param structure found.
- The strip must key on the literal param names `'pass'` and `'rush'`, NOT on `PLAY_TYPE` group membership — checked: `run_play_option` is also a `PLAY_TYPE` boolean (`nfl-plays-column-params.mjs:1310-1313`) and must not be stripped.
- Scope derivation: single `['ball_carrier_pid']` is the only rush pid; `['passer_pid']` and `['target_pid']` are pass; every other value (multi-pid, `['qb_pid']`, `['player_fuml_pid']`, unknown) safely resolves to `any` and is never stripped — checked against the grepped value set; `rec_pid` is dead and omitted.
- Stripping only the opposing `PLAY_TYPE` boolean is sufficient for the demonstrated bug and carries zero false-strip risk because those selections are disjoint from the column's stat — checked against the data. Broader family-group stripping (e.g. a `PASSING`-group range param on a rush column) is deferred pending a separate correctness analysis, not merely a third case (see Future Work).
- A column with no incompatible param produces a byte-identical generated query after this change (no-op path) — to be verified by test.

## Design

- High-level approach: one small server-side normalization step, placed inside the existing param funnel rather than at hand-picked call sites. Derive a column's `play_scope` from `pid_columns`; remove the opposing `PLAY_TYPE` boolean (by literal key) from the column's top-level params and from its nested `output_column_params`. This corrects newly-built and already-shared URLs at the data layer and reaches every query-build path through the one funnel plus the one nested-object recursion. No client, factory, or `react-table` changes.
- New module: `libs-server/data-views/strip-incompatible-play-params.mjs`. The module is extracted (rather than inlined) for unit-testability in isolation; that testability is the justification, not present-day reuse. It exports a single function:
  - `strip_incompatible_play_params({ column_definition, params })` — returns a new params object (never mutates input) with the opposing key removed at top level and inside `output_column_params`/`rate_type_column_params` when present. Returns an equivalent object for scope `any` or when no opposing key is present.
  - `derive_play_scope(pid_columns)` is a private (non-exported) helper inside the module: single `['ball_carrier_pid']` → `'rush'`; single `['passer_pid']` or `['target_pid']` → `'pass'`; everything else → `'any'`. A code comment notes that unrecognized single pids (`player_fuml_pid`, dormant `qb_pid`) intentionally fall through to `any`.
- Strip rule is keyed by literal: scope `rush` deletes `pass`; scope `pass` deletes `rush`. It does not iterate `PLAY_TYPE` group membership (that would wrongly remove `run_play_option`).
- Integration point: inside `process_item_params` (`get-data-view-results.mjs:461`), resolve `column_definition` from `item.column_id` and run the finalized params through `strip_incompatible_play_params`. Because the batch at `:1536-1539` maps all four arrays through this funnel before any consumer, this single edit covers columns, prefix_columns, where, sort, the FROM-table optimization, the output aggregator, and the sort-clause loop. No separate sort-path edit is needed (the earlier-plan reference to "line 641-643" was the FROM-table read, which consumes the already-funnelled sort).
- Scope vocabulary is `rush` / `pass` / `any` only. Receiving columns (`target_pid`) map to `pass` (same opposing boolean, `rush`); there is no separate `rec` scope.
- Behavior preservation: `qb_scramble` (a `RUSHING`-group param, not a `PLAY_TYPE` boolean) is never stripped — a rush column with `qb_scramble: true` renders scramble rush yards. `pass: true` on play-type-agnostic fantasy points (no single offensive pid → scope `any`) is untouched.
- Knowledge Base Impact: extend `███████████████████████████████████████████` troubleshooting with the per-column mutual-exclusivity check; memorialize root cause as observations on this task entity (tag `user:tag/league-xo-football.md`). No ABOUT.md changes; no new guideline.
- Scratch tier: none — bounded code change, no multi-file artifacts.
- Orchestrator shape: none — single bounded task.

## Notes

- Cosmetic caveat (owned by the client-UX follow-up): with a server-only strip, the client's param chip may still display the stripped filter and the picker still offers it. Data is correct; the chip is cosmetically stale and the user gets no signal their filter was ignored. This is strictly better than today's empty column, and the follow-up (`surface-incompatible-data-view-column-params-client-ux`) closes the feedback gap via the existing notice rule engine.
- Do not implement scoping by removing params from `column_params` in the field layer: the param editor renders controls by iterating `Object.keys(column.column_params)` (`react-table` `column-controls-selected-columns-parameters.js:58`), so removing an offered param hides any existing value as an invisible, uneditable orphan while it still applies server-side — strictly worse for already-broken URLs. This is why the fix lives at the server data layer, not the field metadata.

## Plan

### Core Implementation

- [ ] **Add server-side play-scope strip helper**
  - File: `repository/active/league/libs-server/data-views/strip-incompatible-play-params.mjs` (new)
  - Purpose: single, unit-testable place that derives a column's play scope and removes the contradictory opposing play-type boolean, including from nested rate-type params.
  - Changes:
    - Implement private `derive_play_scope(pid_columns)`: guard the argument first — non-array or empty `pid_columns` returns `any` (team-stats-from-plays definitions expose no `pid_columns`, so the argument will be `undefined`; never read `.length`/index before the guard or it throws for every team column). Then: single `['ball_carrier_pid']` → `rush`; single `['passer_pid']`/`['target_pid']` → `pass`; everything else → `any`. Comment that unrecognized single pids (`player_fuml_pid`, `qb_pid`) fall through to `any` by design.
    - Implement and export `strip_incompatible_play_params({ column_definition, params })`: compute scope; for `rush` delete key `pass`, for `pass` delete key `rush`, by literal key name; apply the same deletion inside `params.output_column_params` and `params.rate_type_column_params` when present; return a new object; never mutate input; no-op for `any` or absent key.
  - Verify: new unit test (next item) green.

- [ ] **Unit-test the helper**
  - File: `repository/active/league/test/data-views-strip-incompatible-play-params.spec.mjs` (new)
  - Purpose: lock scope derivation, the literal-key rule, nested stripping, and the no-op/agnostic paths.
  - Changes:
    - Scope derivation cases (via the public function's behavior): `['ball_carrier_pid']`→rush, `['passer_pid']`→pass, `['target_pid']`→pass, `['ball_carrier_pid','target_pid']`→any, `['qb_pid']`→any, `['player_fuml_pid']`→any, `[]`/`undefined`→any (the `undefined` case is the team-column path and must not throw).
    - Strip cases: rush-scope drops top-level `pass` but keeps `qb_scramble` and `run_play_option`; pass-scope (`target_pid` and `passer_pid`) drops `rush`; `any`-scope keeps `pass`; nested `output_column_params: { pass: true }` on a rush column is stripped; absent opposing key returns an equivalent object; input object is not mutated.
  - Verify: `yarn test test/data-views-strip-incompatible-play-params.spec.mjs` green.

- [ ] **Wire the strip into the param funnel**
  - File: `repository/active/league/libs-server/get-data-view-results.mjs`
  - Purpose: apply the strip to every per-column param set from the one funnel that all four arrays pass through before query build.
  - Changes:
    - Import `strip_incompatible_play_params`.
    - In `process_item_params` (`:461`), after `process_params_with_backwards_compatibility`, resolve `column_definition = data_views_column_definitions[item.column_id]` and replace `params` with `strip_incompatible_play_params({ column_definition, params })` (guard when `column_definition` is absent — leave params untouched; this guard also covers sort/prefix entries that carry no resolvable `column_id`). String-form items already short-circuit at the existing `typeof item === 'object'` check and never reach the strip.
    - Do not add a separate sort-path strip: the sort array is funnelled at `:1539` before the FROM-table read (`:1546`) and sort-clause loop (`:1989`), so both consume stripped params.
  - Verify: the integration check below shows the debugged view's rush column non-empty, and a WHERE-only `pass` filter on a rush column is also neutralized.

### Testing & Validation

- [ ] **Reproduce-and-fix integration check on the real view (population-level)**
  - Purpose: confirm the fix reaches the population that matters (existing shared URL) and the rate-type path.
  - Changes/steps:
    - Resolve `/u/1cd5ab54e78dba202eef0bca26c14934` to its `table_state` (shared resolver or debug endpoint, which bypasses cache) and run `get_data_view_results` against production data.
  - Verify: the `player_rush_yards_from_plays` column (a `per_game` rate-type column) is non-null for at least 50 rushing players (population check, not a single row); pre-fix this count is 0. Failure mode caught: strip not applied on the funnel or the nested rate-type denominator path.

- [ ] **Path-coverage check: WHERE-only and output_column_params**
  - Purpose: prove the funnel placement covers paths the original two-site design would have missed.
  - Changes/steps:
    - Build a table_state with `pass: true` on `player_rush_yards_from_plays` placed only in `where` (not as a displayed column), run it, and confirm the result is non-degenerate (filter neutralized).
    - Build a `per_game` rush column with `pass: true` nested in `output_column_params`, run it, and confirm the denominator is computed over rush plays (non-zero), not zero.
  - Verify: both queries return non-degenerate results; inspecting the generated SQL shows no `pass = true` predicate on the rush CTE or its denominator.

- [ ] **No-op regression check for valid configurations**
  - Purpose: prove the change is inert when no incompatible param is present and preserves agnostic/compatible columns.
  - Changes/steps:
    - Capture the generated query string for a normal rush column (no `pass`) before and after the change and assert string equality, using the query-capture pattern in `test/data-views-output-parity.spec.mjs`.
    - Confirm a `player_fantasy_points_from_plays` column with `pass: true` (scope `any`) returns pass-play fantasy points unchanged.
    - Confirm a rush column with `qb_scramble: true` returns scramble rush yards (qb_scramble preserved).
  - Verify: query string identical for the no-op case; fantasy-points and qb_scramble columns return non-null expected values.

- [ ] **Cache bust for known-affected URLs after deploy**
  - Purpose: ensure existing-URL users see the fix rather than a stale cached empty result.
  - Changes/steps:
    - After deploy, evict cache for the debugged hash (at minimum `1cd5ab54e78dba202eef0bca26c14934`) — restart the API process or evict the cache key — then fetch the URL through the normal cached path.
  - Verify: the cached fetch returns the rush column non-empty (not the stale empty result). If accepting TTL-bounded staleness instead, record that decision explicitly.

### Knowledge Base Updates

- [ ] **Extend the debug workflow troubleshooting**
  - File: `██████████████████████████████████████`
  - Purpose: surface this diagnosis so the next debug session catches it directly.
  - Changes: under "Empty Results", add a check for a single column whose own play-type params are mutually exclusive with its stat (e.g. `pass` on a ball-carrier/rush column, `rush` on a passer column), independent of any `where` filter.
  - Verify: `grep -q "mutually exclusive" ██████████████████████████████████████` succeeds.

- [ ] **Memorialize root cause on this task entity**
  - Purpose: durable record that this was a config/contradiction issue, not a backend regression, with the data evidence.
  - Changes: add observations via `base entity observe` (root cause; the 2019-2025 data evidence that scrambles are `rush = true`; the funnel-based server-strip approach).
  - Verify: `base entity get` for this task shows the new observations.

## Future Work (tracked separately, not part of this task)

- Client UX completion — picker suppression of incompatible params, stale-chip cleanup, and a `data-view-notices.mjs` self-contradiction rule — is tracked as `user:task/league/surface-incompatible-data-view-column-params-client-ux.md` (Draft), which is `blocked_by` this task. The notice engine ships rule-by-rule, so the rule itself is small; the open question it carries is the client-side scope signal (likely a server-returned stripped-params field), which is why it is sequenced after this task rather than bundled.
- Broader family-group stripping (params beyond the opposing `PLAY_TYPE` boolean whose play population is disjoint from the column's stat, e.g. a `PASSING`-group range on a rush column) is intentionally not tracked yet: it requires a separate correctness analysis of which param groups are structurally disjoint per pid scope (a wrong broad strip that drops a legitimately-applicable param is worse than the current bug), not merely a third observed case.
