---
title: Collapse the single_nfl_week_id param key onto nfl_week_id
type: task
status: in_progress
description: >-
  single_nfl_week_id is stored under its own param KEY rather than as a definition under
  nfl_week_id, so every server consumer reads both and the bulk param editor renders two
  indistinguishable NFL Week controls. Collapse the key, repair the v0 migration that rewrites it,
  and migrate all four persistence surfaces. Arity, single_position, and two react-table defects
  split to sibling tasks.
base_uri: user:task/league/collapse-single-variant-column-param-keys.md
created_at: '2026-09-01T20:59:11.099Z'
entity_id: 7b5b5141-2733-4b76-ae99-04ae810e6576
observations:
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
owner_identity_uri: █████████████████████████
priority: Medium
public_read: true
relations:
  - succeeds [[user:task/league/migrate-weekly-fields-to-single-nfl-week-id.md]]
  - succeeds [[user:task/league/refactor-nfl-week-id-column-param-ux.md]]
  - relates [[user:repository/active/react-table/ABOUT.md]]
  - precedes [[user:task/league/collapse-single-nfl-week-id-onto-nfl-week-id-arity.md]]
  - precedes [[user:task/league/collapse-single-position-param-key.md]]
  - relates [[user:task/league/fix-bulk-param-editor-reset-reporting-and-index-space.md]]
  - relates [[█████████████████████-██████████-███████-██████-█████-██████████████]]
started_at: '2026-09-20T00:25:52.995Z'
tags:
  - user:tag/league-xo-football.md
  - user:tag/software-task.md
updated_at: '2026-10-05T15:55:05.572Z'
visibility_analyzed_at: '2026-11-05T23:48:34.682Z'
---

# Implementation Plan: Collapse the `single_nfl_week_id` Param Key onto `nfl_week_id`

## Overview

- Collapse the param key `single_nfl_week_id` onto `nfl_week_id`, keeping both definitions distinct. The single-valued variant moves from the KEY into the DEFINITION, which is what `single_year` / `year` already does at every one of its use sites.
- Remove the dual-key reads this forces on the server, and the duplicated component binding on the client.
- Repair `data-views-nfl-week-migration.mjs`, which WRITES the legacy key on two read paths and would otherwise undo the collapse on every short-URL load.
- Migrate saved views, browser snapshots and short URLs, and rewrite the durable rows once.
- Land on the N-record param resolver from [[███████████████-█████████████-█████-███████████-██████-██████████]], which is a prerequisite rather than part of this task — it fixes a defect live today on `year` and `year_offset`, and without it the collapse lets a bulk write push `current_year_reg_weeks` onto a game-prop column.

**Impact.** Bulk-editing a mixed selection of projection and betting-market columns stops rendering two indistinguishable "NFL Week" controls that each reach only half the selection — the reported defect. Any future consumer of the week param reads one key and cannot be silently half-right. The 37 █████ █████ carrying the legacy key render their week params correctly in the editor — note the QUERY they produce already survives today, because `get-data-view-results.mjs:130-163` rewrites any registered legacy key on every request, so the parser step's value is client-side only: editor display, the view hash, and re-save.

**Ordering.** Follows [[user:task/league/migrate-weekly-fields-to-single-nfl-week-id.md]], which introduced the twin key, and [[user:task/league/refactor-nfl-week-id-column-param-ux.md]], which built the selector. **Blocked by [[███████████████-█████████████-█████-███████████-██████-██████████]]** — the resolver must exist before the collapse puts a second definition under `nfl_week_id`. Three further pieces were split out and do NOT block: [[user:task/league/collapse-single-nfl-week-id-onto-nfl-week-id-arity.md]], [[user:task/league/collapse-single-position-param-key.md]], [[user:task/league/fix-bulk-param-editor-reset-reporting-and-index-space.md]].

## Background

### The reported defect

Bulk-editing five selected columns (two `Projected * (Week)`, three `Game Prop Line`) renders two visually identical "NFL Week" controls; editing one changes only its half of the selection. Both definitions carry `label: 'NFL Week'`, and `app/core/data-views-fields/index.js:180-187` binds the same `ColumnParamNflWeekSelector` to both keys, so nothing distinguishes them on screen.

`react-table/src/parameters-editor/parameters-editor.js:37-50` builds `supporting_records_by_param` from `Object.keys(definition.column_params)`, and `:81-86` places a param in the "Shared" section only when its supporting-record count equals the selection size. Two keys means two disjoint lists (2 records and 3 records against a selection of 5), so both fall to "All" under the same label.

**Verified that the collapse fixes it**: with both declarations keyed `nfl_week_id`, `supporting_records_by_param.get('nfl_week_id').length === 5 === records.length`, the param enters `shared`, and `parameters-editor-item.js:48-52` loops `record.update` over all five. Neither definition sets `hidden`, and `group_parameters` runs per section, so nothing re-splits it.

### The key convention exists and two params broke it

`libs-shared/common-column-params.mjs` exports six `single_*` twins. Four keep the canonical key and carry the variant in the definition — `single_year` is keyed as `year` at every use site, `single_year_offset` as `year_offset`. Two acquired their own key through object shorthand: `single_nfl_week_id` and `single_position`.

The convention analogy was checked rather than assumed: `year` carries `dynamic_values` that `single_year` does not (`:193-219`), and `week` / `single_week` narrow the same way (`:222-257`). Arity plus narrowed dynamics is exactly the `nfl_week_id` / `single_nfl_week_id` relationship, so the key collapse is a faithful fourth application of a convention already in the file. (The ARITY declaration is a different inheritance and does not carry over; it is split to its own task for the reasons recorded there.)

### What the twin key costs

Reproducing command for the file inventory: `grep -rln 'single_nfl_week_id' --include=*.mjs --include=*.js --include=*.json . --exclude-dir={node_modules,dist,.git}`. Run it rather than trusting a count in this document; the tree moves.

Consumers read both keys and pick a precedence: four functions in `libs-server/data-views/resolve-single-nfl-week-id.mjs` each re-implement `single_nfl_week_id ?? nfl_week_id`, and `apply-scope-to-query.mjs:46,80`, `resolve-view-scope.mjs:53`, `validate-week-requirement.mjs:53` and `get-data-view-results.mjs:412` each read both while treating them identically. `get-data-view-results.mjs:398-400` records a shipped bug that was one consumer gated on `nfl_week_id` alone.

Only TWO column-definition modules hand-read a legacy key — `player-projected-column-definitions.mjs:31-32` and `nfl-team-seasonlogs-column-definitions.mjs:18-20` (the latter is `single_position`, out of scope here). The other six go through `resolve_single_nfl_week_id_if_explicit` and need no edit.

`libs-server/game-prop-column-resolution.mjs:349-364` is a WRITER, not a reader: it builds `single_nfl_week_id: identifiers` with up to a full season of identifiers.

### The module that would undo this change

`libs-shared/data-views-nfl-week-migration.mjs:113-121`, for each of the 24 `SINGLE_WEEK_COLUMNS` (game props, DFS salary and ownership, practice, roster status), does `params.single_nfl_week_id = [identifier]` then `delete params.nfl_week_id`. It runs on the short-URL parser (`parse-table-state-from-url.mjs:138-146`, via `migrate_entries_array`) and on the `v0_to_v1` browser-storage step.

Left unrepaired, it re-mints the legacy key and deletes the canonical one on exactly the links this task exists to fix, and `resolve_single_identifier:52-56` accepts only static identifiers, so a dynamic value reads as absent and a multi-week list collapses to element zero.

### Production shape

Reproducing commands, run read-only. **The population is live and moved during planning** (2,562 column entries at first measurement, 2,561 an hour later) — re-baseline at apply time rather than reading numbers out of this document.

```sql
-- per-surface legacy-key census
with cols as (
  select c as col from user_data_views v,
    lateral json_array_elements(v.table_state->'columns') c
  where json_typeof(v.table_state->'columns') = 'array')
select count(*) as column_entries,
  count(*) filter (where col->'params'->'single_nfl_week_id' is not null) as has_single,
  count(*) filter (where col->'params'->'nfl_week_id' is not null) as has_multi,
  count(*) filter (where col->'params'->'single_nfl_week_id' is not null
                     and col->'params'->'nfl_week_id' is not null) as has_both
from cols
```

Repeat the lateral over `'where'`, `'prefix_columns'` and `'sort'` for the other surfaces — the query above covers only `columns`. At planning time: `columns` carried the key on 579 entries and `where` on 6, with `prefix_columns` and `sort` at zero, and NO entry carried both keys. `user_plays_views` holds 0 rows.

```sql
select count(*) as total_urls,
  count(*) filter (where url like '%single_nfl_week_id%') as with_single_nfl_week_id
from urls
```

938 URLs, 37 carrying the key.

### The migration machinery

`RENAME_REGISTRY` (`libs-shared/data-views-saved-view-migration.mjs:52`) carries `level: 'param_key'` batches. `PARAM_KEY_RENAMES` merges them in declared order with chain resolution, `MIGRATED_PARAM_KEYS` feeds the orphan gate, and `build_param_key_rewrite()` gives the server request boundary a one-hop terminal at `get-data-view-results.mjs:141`.

Two corrections to how far a batch reaches, both checked:

- **The `surfaces` array is inert at `param_key` level.** `PARAM_KEY_RENAMES` is an `Object.assign` over every `param_key` batch with no surface filter, and `reaches_surface` is applied only to `TABLE_STATE_RENAMES`. Declaring `short_url` documents intent; it delivers nothing. Short-URL reach comes solely from the separate parser step below.
- **The saved-view read path corrects every view on read** (`app/core/data-views/reducer.js:43`), so the durable DB rewrite is hygiene, not correctness. `app/core/plays-view/browser-storage.mjs:3,143` routes plays views through `migrate_plays_view_table_state` from the same module; it is the plays variant, not `migrate_saved_view_table_state`.

The deferral this change closes is documented at `data-views-saved-view-migration.mjs:1030-1040` ("Narrow on purpose — column ids only, not the param-key maps... still unmeasured against the 863 URLs"). It is now measured.

### Assumptions

- No saved-view entry carries both keys, on any surface. Checked: the census query above at planning time; re-run at apply time.
- 37 short URLs carry the key. Checked: the `urls` query above.
- The collapse lands the param in the editor's Shared section and the write reaches all five records. Checked by tracing `parameters-editor.js:37-50,81-86` and `parameters-editor-item.js:48-52`, including the `hidden` and `group_parameters` branches.
- Only two column-definition modules hand-read a legacy key. Checked: per-file grep for `params.single_nfl_week_id` / `params.single_position` across all eight.
- UNCHECKED, and deliberately so: that every server consumer decides arity at its own call site rather than from which key it found. This is a structural reading of twelve sites, not an observation. The result-equivalence run in Testing is what converts it to evidence; treat a non-`equivalent` fixture as this assumption failing.
- UNCHECKED: that no consumer outside this repository reads the key from a `table_state` payload. `private/` is an unchecked-out submodule. Mitigated by the server request boundary, which rewrites legacy keys on every request.
- Nothing pins param key names in a schema. Checked: `libs-server/validators.mjs:264-274`, `libs-shared/data-view-storage/validate.mjs`, and react-table's `schema/state/where-item.json:20`, `columns/column-reference.json:11`, `columns/column-definition.json:117` all type `params` as a free object. The one place enumerating allowed keys is the generation catalog, handled below.

## Design

**The rule: a param's key is canonical; the variant lives in the definition.**

- **Declarations.** `common-column-params.mjs` keeps its export names. The three field modules binding by object shorthand change to the explicit form `nfl_week_id: single_nfl_week_id`, matching the existing `year: single_year`. No definition changes.
- **Server consumers.** The dual reads collapse to `params.nfl_week_id`. Arity stays decided by the call site.
- **The v0 migration.** `data-views-nfl-week-migration.mjs`'s `SINGLE_WEEK_COLUMNS` branch writes `nfl_week_id` and stops deleting it. It keeps using `resolve_single_identifier`, so those columns still resolve to one identifier — behavior is unchanged, only the key it lands under. Ordering is then stated explicitly: the param-key rewrite runs FIRST so a legacy URL is canonicalized before this module sees it.
- **Short URLs.** `parse-table-state-from-url.mjs` applies param-key renames to parsed `columns` and `where` entries. It must NOT route through the exported `migrate_column_entry`, which also applies `COLUMN_ID_RENAMES` (already applied separately at `:137`) and the `TEAM_FROM_PLAYS` rewrite — that would newly apply two further transforms to 938 URLs. Export a narrow param-key-only helper from the migration module instead; its consumer is this call site.
- **Migration batch.** One `param_key` batch, id `SINGLE_PREFIX` (named for the legacy affix, beside the existing `SIDE_PREFIX` and `BOOLEAN_PREFIX`; no batch id restates its own `level`), carrying the single record `{ single_nfl_week_id: 'nfl_week_id' }`. `retirable: false` — a short URL can carry the legacy name, so read-time rewriting is the permanent end state.
- **Collision rule.** When one entry carries both keys the canonical wins and the legacy value is dropped, decided on key presence, uniformly across every surface. Zero production entries exercise it; the rule is asserted in a unit test so the branch is decided rather than accidental. **Do not justify it as "the legacy key is the stale copy by construction"** — that reasoning fails if the canonical key was DEFAULT-FILLED rather than user-set, since `react-table/src/utils/resolve-column-params.js:107-146` fills unset declared params under `fill_unset`, which would let canonical-wins discard a real week in favor of a manufactured current-week default. Before implementing, establish whether `fill_unset` can run on the URL-load path; if it can, the rule must prefer the value the user actually set rather than the key it sits under.
- **react-table merge.** `parameters-editor.js` replaces `if (!defs[name]) defs[name] = def` with a merge across supporting records.

  **The merge is a pre-existing bug fix, not a consequence of the collapse.** `app/core/data-views-fields/league-format-logs-table-fields.js:34,36` already binds `year: single_year` and `year_offset: single_year_offset` while other modules bind the plain definitions, so two different definitions already share one key today and iteration order already decides between them. An earlier draft of this plan claimed the collapse was what first created that situation and used it to justify deleting the live reproduction step. That was wrong, and the reproduction is restored below — `year_offset` regresses on ship, before this task's own key is involved.

  Rules, each tied to a checked failure:
  - Intersect `values` through `resolve_param_values`, not raw `values` — `betting-market-table-fields.js:55` declares `get_values` for `selection_type` on these very columns, and a raw intersection yields empty. **Open: `resolve_param_values` takes a `params` argument and a merge has no single params object. Settle what it is passed before implementing; the rule is not yet well-formed.**
  - Take `single` as the most restrictive across records. **Arity is NOT out of scope for the merge**, contrary to an earlier draft: `is_single_select` (`react-table/src/column-param-select-filter/column-param-select-filter.js:195-205`) reads `enable_multi_on_split` only when `single` is truthy, so a merge that unions the one and ignores the other is incoherent. Without a `single` rule, under key `year` the arity comes from whichever record iterates first — the exact nondeterminism the merge exists to remove.
  - Union `enable_multi_on_split`, but note the justification an earlier draft gave was false: neither `nfl_week_id` nor `single_nfl_week_id` declares it. The only two declarations in the tree are `single_year` (`common-column-params.mjs:219`) and `single_week` (`:251`), so this rule exists for the `year` key, not for this task's key.
  - Drop `default_value` ONLY when the first record's default falls outside the merged set — not whenever records disagree. Dropping it wholesale flips write paths rather than labels: `column-param-select-filter.js:65,71` flips `set_null_on_all_click`, so "All" writes `null` instead of the value list. `year_offset` versus `single_year_offset` differ on `default_value` (absent versus `0`) and on `is_single`, so a wholesale drop makes `column-param-range-filter.js` fall through to `default_value ?? min`, which is `-30`.
- **Gates.** No new spec file. `data-views.generation-catalog-param-coverage.spec.mjs` must be edited anyway — its `REQUIRED_PARAM_KEYS` hard-codes the legacy key — and `build-data-view-generation-catalog.mjs:159-181` already enumerates every field module's `column_params` keys into `column.param_keys`. The canonical-key rule is four lines there. Separately, `data-views.column-param-ui-contract.spec.mjs` goes GREEN BUT BLIND after the collapse: it resolves custom-rendered params by EXPORT name while its regex reads KEY names from the binding site, and the two stop coinciding. That is a gate losing coverage as a direct result of this change and is repaired here.

**Knowledge Base Impact.** [[user:text/league/data-view-storage-architecture.md]] goes stale on the storage version and the new short-URL surface. Repo-local `docs/data-views-system.md` and `docs/data-views-architecture.md` carry the param vocabulary. No ABOUT.md change and no new tag. One observation candidate for [[user:guideline/write/write-software.md]] on the general shape: a variant encoded in a key rather than a definition forces every consumer into a dual read, and a rename is not lossless when a MIGRATION writes the key being renamed.

**Scratch tier.** `scratch/league/collapse-single-variant-column-param-keys/`, owned by this task. Holds the short-URL baseline, which is a digest of 37 production URLs too large to embed here. Retired via [[user:workflow/knowledge-base/retire-scratch-domain.md]] when this task closes — the baseline has no value once the before/after comparison is done.

**Orchestrator.** Not warranted; one task entity with three split siblings.

## Notes

- Work in a worktree per [[user:workflow/software/setup-worktree.md]]. Three worktree-specific traps from `docs/guides/test.md`: `.bin` shims are broken there, so use `node node_modules/mocha/bin/mocha.js`, `node node_modules/prettier/bin/prettier.cjs`, `node node_modules/eslint/bin/eslint.js`; a fresh worktree needs `sandbox-install yarn install` (the bare form is blocked fleet-wide); and `yarn test:local` cannot run there at all. `yarn test:isolated` DOES work — it runs `scripts/test-isolated.sh` against the shared `:5433` container and never calls `test:db:up`.
- react-table is not a user-base submodule, so `cli/sync/sync-all.sh` will not push it. Push manually before bumping the pin.
- **A local-link path DOES exist, contrary to an earlier draft of this note.** [[user:guideline/software/develop-react-table.md]] Path B is a Yarn 4 `portal:` override and is MANDATORY for react-table UI changes needing browser verification in a consumer; Path A (push and bump) is for final verification only. Fall back to the direct `node_modules` symlink swap when the portal fetcher throws the locator error, and restore the pin resolution before committing consumer changes. This means the two repos do not have to be serialized as tightly as this plan first assumed.
- **Coordinate on the pin.** [[█████████████████████-██████████-███████-██████-█████-██████████████]] owns bumping `package.json:194` to react-table `cac794a` and has not run its browser pass. This task's bump stacks on that commit and would deploy it unverified. That sibling is also parked on a false blocker — its unblock condition greps `origin/master`, but the repo's branch is `origin/main` and the commit is already there.
- **Deploy ordering is a measurement dependency, not a code one.** The undeployed week-scoped column migration changes what the same █████ █████ render, through the same params. Deploying both in one cutover leaves the short-link before/after check unable to attribute a difference. Either sequence them, or capture the short-link baseline before the week migration deploys.
- Migrating the views changes their `get_data_view_hash`, which is computed on the raw request at `api/routes/data-views.mjs:1084,1247,1478` and `api/sockets/data_view.mjs:84` — BEFORE the rewrite at `get-data-view-results.mjs:141`. Expect one cold miss across the data-view redis cache. Not a correctness issue; say so before deploy rather than debugging it after.

## Plan

### Prerequisite

- [ ] **Land the N-record param resolver first**
  - Task: [[███████████████-█████████████-█████-███████████-██████-██████████]]
  - Purpose: the collapse puts a second definition under `nfl_week_id`, and without the resolver `parameters-editor.js:52-65` picks between them by iteration order — letting a bulk write push `current_year_reg_weeks` onto a game-prop column and reproducing the week-one collapse. That task also fixes the live `year` / `year_offset` case, which is why it is a prerequisite rather than a phase of this one.
  - Verify: the resolver is merged and its `year_offset` reproduction is recorded. Iterate against it locally with the Yarn 4 `portal:` override per [[user:guideline/software/develop-react-table.md]] Path B rather than waiting on a pin bump.

### league: collapse the key

- [ ] **Key the declarations canonically**
  - Files: `app/core/data-views-fields/betting-market-table-fields.js:72`, `player-dfs-salaries-table-fields.js:13`, `practice-table-fields.js:13`
  - Purpose: the object-shorthand spelling is what minted the twin key.
  - Changes: replace the shorthand with `nfl_week_id: single_nfl_week_id`.
  - Verify: the widened catalog assertion below, which walks every field module's param keys. No separate per-module check.

- [ ] **Delete the duplicate component binding**
  - File: `app/core/data-views-fields/index.js:184-187`
  - Verify: `grep -c single_nfl_week_id app/core/data-views-fields/index.js` returns 0. (No verify beyond the grep — mechanical step.)

- [ ] **Read one key across the server consumers**
  - Files: `libs-server/data-views/resolve-single-nfl-week-id.mjs` (14 occurrences across four functions — read each, do not sed), `apply-scope-to-query.mjs`, `resolve-view-scope.mjs`, `validate-week-requirement.mjs`, `week-scoped-cte.mjs`, `get-data-view-results.mjs`, `libs-server/data-views-column-definitions/player-projected-column-definitions.mjs:31-32`
  - Purpose: remove the dual read and the four re-implementations of one precedence.
  - Changes: read `params.nfl_week_id`; delete the fallbacks and the comments that existed only to explain the second key.
  - **`week-scoped-cte.mjs` is not mechanical, despite reading that way.** `resolve_week_scope` is a SINGLE resolution that the CTE, the join predicate and the table alias hash all call, and it is single because when those three were written separately they drifted in three distinct ways, one of which shipped to production and was reported by a user. A collapse that leaves all three reading the same resolution is safe; one that gives any of them a different read reproduces that defect class, and it surfaces as wrong values rather than as an error. Confirmed with the session that owns that module.
  - Verify: `grep -rn 'params\.single_nfl_week_id' libs-server/ libs-shared/ app/` returns EXACTLY ONE hit — `data-views-nfl-week-migration.mjs:53`, inside `resolve_single_identifier`, which must keep reading the legacy key because its whole job is migrating v0 state that still carries it. A zero-expectation grep here would fail against the very file the plan repairs; state the expected hit rather than expecting none. THEN `test/data-views.week-scoped-column-gate.spec.mjs`, which asserts the join half for all 13 declared week-scoped columns and has been red-proved twice; treat it going red as a real finding, never as a fixture to update.

- [ ] **Repoint the writer**
  - File: `libs-server/game-prop-column-resolution.mjs:349-364`
  - Purpose: it WRITES the legacy key; the sweep's read-only framing would have missed it.
  - Changes: emit `nfl_week_id`.
  - Verify: the result-equivalence run below must report `equivalent` for the game-prop fixtures, which is the only check that sees this call site's output.

- [ ] **Repair the v0 migration so it stops re-minting the legacy key**
  - File: `libs-shared/data-views-nfl-week-migration.mjs:113-121`
  - Purpose: it sets `single_nfl_week_id` and deletes `nfl_week_id` for 24 columns on the short-URL and v0-snapshot paths. Unrepaired, it undoes this change on exactly the links the change exists to fix.
  - Changes: the `SINGLE_WEEK_COLUMNS` branch writes `nfl_week_id`, drops the `delete params.nfl_week_id`, and ADDS `delete params.single_nfl_week_id` — without that last part a v0 snapshot carrying the legacy key exits `v0_to_v1` holding both, cleaned only incidentally by `v1_to_v2`. Keep `resolve_single_identifier` so those columns still resolve to one identifier.
  - **This is not a v0-only path.** `migrate_entries_array` runs on EVERY short URL unconditionally (`parse-table-state-from-url.mjs:138,142,146`), so the repair is load-bearing independently of whether a v0 snapshot can still arrive.
  - Verify: invert the two EXISTING specs rather than adding a third — `test/data-view-storage.migrations.spec.mjs:33-35` asserts the v0 chain yields `params.single_nfl_week_id === ['2024_REG_WEEK_5']`, and `test/data-views-parse-table-state-from-url.spec.mjs:132-133` asserts the parsed URL carries the legacy key. Both go red on this change and both must be flipped to the canonical key. The URL spec's case at `:116` supplies `year`/`week`/`seas_type` with NO legacy key, which is independent proof the repair is needed regardless of any key-rewrite ordering.

### league: migrate every persistence surface

- [ ] **Declare the rename batch**
  - File: `libs-shared/data-views-saved-view-migration.mjs`
  - Changes: add a `param_key` batch, id `SINGLE_PREFIX`, records `{ single_nfl_week_id: 'nfl_week_id' }`, placed in the declared merge order after the batches whose targets it does not chain through; state the collision rule in the batch prose.
  - Verify: unit spec asserting `PARAM_KEY_RENAMES.single_nfl_week_id === 'nfl_week_id'`, that `build_param_key_rewrite()` resolves it in one hop, and that an entry carrying both keys yields the canonical value.

- [ ] **Export a param-key-only rewrite and apply it to short URLs**
  - Files: `libs-shared/data-views-saved-view-migration.mjs`, `app/core/data-views/parse-table-state-from-url.mjs`
  - Purpose: 37 production links carry the legacy key and receive no param-key rename today. `migrate_params` is module-private and the exported `migrate_column_entry` wrapper also applies column-id renames (already applied at `:137`) and the team-from-plays rewrite — routing URLs through it would newly apply both to 938 links.
  - Changes: export a narrow param-key-only helper; call it on parsed `columns` and `where` entries, AFTER `migrate_entries_array`, and state that ordering in a comment.
  - **Ordering, settled by audit — after, not before.** Running the rewrite AFTER strictly dominates: all four cases land on the canonical key (unrepaired module with a static or dynamic value, repaired module with either). Running it BEFORE is safe only when conjoined with the repair above, because against the unrepaired module a DYNAMIC value fails `is_static_identifier` at `:50`, resolves to null, and is then deleted outright at `:118` — unrecoverable. My earlier rationale for "before" was that the rewrite would otherwise hide the value from `resolve_single_identifier`; that is false, since `:55` falls back to `params.nfl_week_id`. Do not enshrine the "before" reasoning in a comment — it states a necessity that does not exist.
  - Verify: extend `test/data-views-parse-table-state-from-url.spec.mjs` with a URL carrying the legacy key inside a game-prop column's params, asserting the parsed state carries `nfl_week_id`. Negative control: assert it fails against the pre-change parser. Second case: the same URL must survive `migrate_entries_array` without the key being re-minted.

- [ ] **Bump the browser storage chain**
  - File: `libs-shared/data-view-storage/migrations.mjs`
  - Changes: `STORAGE_SCHEMA_VERSION` to 4, plus a `v3_to_v4` re-running `migrate_saved_view_table_state`, since a version-3 snapshot skips `v1_to_v2`.
  - Verify: extend `test/data-view-storage.migrations.spec.mjs` with a version-3 snapshot carrying the legacy key, asserting it emerges at 4 canonical.

- [ ] **Sweep the test tree by path and by symbol**
  - Files: every path returned by the inventory grep, including `test/data-view-benchmark/corpus.json` (bulk, mechanical), `test/resolve-single-nfl-week-id.spec.mjs`, and the query snapshots under `test/data-view-queries/`
  - Purpose: fixtures hand-built on the old keys do not surface in a symbol grep.
  - **`test/data-views.nfl-week-dynamic-contract.spec.mjs` is a considered edit, not a fixture rewrite.** It mixes the two usages line by line: `:121-136` builds `params = { single_nfl_week_id: [dynamic] }` and goes red once the consumers read one key, while `:179-184` filters `common_column_params` by EXPORT name and appends `'single_nfl_week_id'` — renaming that one to `nfl_week_id` points the check at the MULTI definition, which declares many-valued dynamic types and then fails `expect(resolved.length).to.equal(1)`. Keep `nfl_week_param_keys` (`:47`) covering the single definition under the canonical key.
  - Changes: rewrite fixture keys. Regenerate snapshots with `node scripts/update-test-queries.mjs --file <name>.json --confirmed`, once per affected fixture — the script takes a required `--file`, updates exactly one fixture per run, and is dry-run without `--confirmed`.
  - Verify: full suite green. Note that regeneration OVERWRITES `expected_query`, so it proves nothing on its own; the equivalence run below is the oracle.

### league: keep the gates honest

- [ ] **Widen the catalog spec instead of adding a file**
  - File: `test/data-views.generation-catalog-param-coverage.spec.mjs`
  - Purpose: its `REQUIRED_PARAM_KEYS` hard-codes the legacy key so it must be edited anyway, and `build-data-view-generation-catalog.mjs:159-181` already enumerates every field module's param keys into `column.param_keys` — the exact walk a new spec would duplicate.
  - Changes: update the required keys, and assert no `param_keys` entry equals `single_nfl_week_id`. **Not a blanket `single_` prefix rule** — measured against the live registry, `single_position` appears on 114 columns and is split to a sibling task, so a prefix rule would be red before and after this change and would destroy its own red-control. Widen it to the prefix form in the sibling task that removes the last `single_` key.
  - The catalog does NOT reach four field modules — `read-client-column-params.mjs:76-99` skips `index.js` and anything importing React or `@components`, carving out `fantasy-league-`, `game-`, `player-` and `team-table-fields.js`. None declares a `single_*` key today, so nothing is silently skipped now, but nothing ties the rule's scope to the carve-out list either. Pair the assertion with a source-text check over those four so a key added there later cannot pass unseen.
  - Verify: reintroduce `single_nfl_week_id` in one of the three edited field modules, confirm the assertion goes RED, then remove it and confirm green. A gate not shown to go red is not evidence.

- [ ] **Repair the contract spec's blindness**
  - File: `test/data-views.column-param-ui-contract.spec.mjs`
  - Purpose: it resolves custom-rendered params by EXPORT name while its regex reads KEY names from the binding site. Today those coincide; after the collapse the key `nfl_week_id` resolves to the multi definition and the single definition stops being inspected. The gate goes green and blind as a direct result of this change.
  - Changes: resolve the definition through the field module's actual `column_params` value rather than by export name, so the binding site and the inspected definition are the same object.
  - Verify: inject an inert key onto the single-week definition and confirm the spec reports it. Confirm it does NOT report it before this repair — that is the blindness being demonstrated.

- [ ] **Reconcile the generation catalog's shared-param entries**
  - File: `libs-server/data-views/generation/build-data-view-generation-catalog.mjs`
  - Purpose: `build_shared_params` keys by registry EXPORT name while `build_column_params` keys by param key, so after the collapse the two definitions remain distinct shared entries with different defaults while the column entry merges them under one key. `resolve-generated-table-state.mjs:178-205` validates generated params against this catalog.
  - Changes: decide and encode which definition backs `nfl_week_id` for a betting-market column, and record the reason inline.
  - Verify: the catalog spec asserts the column's `param_keys` and the shared entry agree; a generated table state naming `nfl_week_id` on a game-prop column validates.

- [ ] **Bump the react-table pin**
  - File: `repository/active/league/package.json:194`
  - Changes: point at the pushed SHA, after coordinating with the sibling task per Notes.
  - Verify: `sandbox-install yarn install` resolves and `yarn build` succeeds. (Mechanical beyond those.)

### Knowledge Base Updates

- [ ] **Reconcile the storage architecture document**
  - File: [[user:text/league/data-view-storage-architecture.md]]
  - Changes: record the new storage version and that short URLs now receive param-key renames.
  - Verify: the document's statement of which migrations reach short URLs matches `parse-table-state-from-url.mjs` after the change.

- [ ] **Reconcile the repo-local data-view documentation**
  - Files: `docs/data-views-system.md`, `docs/data-views-architecture.md`, and `db/checks/registry.mjs` (a `repair_command` naming the legacy key)
  - Verify: `grep -rn single_nfl_week_id docs/ db/checks/registry.mjs` returns nothing. Establish first that it currently returns something, or the check is vacuous.

- [ ] **Record the general shape as an observation**
  - File: [[user:guideline/write/write-software.md]]
  - Changes: a `[design]` observation — a variant encoded in a KEY rather than a definition forces every consumer into a dual read; and a key rename is not lossless when a MIGRATION writes the key being renamed, which no symbol sweep of readers will surface.
  - Verify: `base entity get` shows it.

### Testing & Validation

- [ ] **Result equivalence across refs — the primary oracle**
  - Command: `NODE_ENV=production node scripts/data-view-regression-check.mjs --base-ref origin/main --regen-base --regen-head --json`
  - Purpose: compares generated SQL between two refs, executes anything that changed against production in a read-only transaction, and hashes the rows. `--regen-base` / `--regen-head` are required here because this change rewrites fixture param keys, so the stored `expected_query` drifts on both sides and cannot be trusted — the script's header names this exact case.
  - Verify: every changed fixture reports `equivalent`. A `regression` verdict means a param stopped reaching its query, which is this change's central risk and the thing `update-test-queries.mjs` structurally cannot detect. Run BEFORE regenerating snapshots.

- [ ] **Full suite on a private database**
  - Command: `yarn test:isolated`, or in a worktree the direct-mocha form from `docs/guides/test.md`
  - Verify: green, with the pre-change failure count recorded so a newly-broken test is distinguishable from a pre-existing one.

- [ ] **Back up before the durable rewrite**
  - Purpose: `scripts/migrate-data-views-saved.mjs` takes no backup, has no inverse, applies every batch in the registry rather than only the new one, and overwrites `updated_at` — destroying the only column that could identify the rows it touched. Rollback is otherwise impossible.
  - Changes: `pg_dump` `user_data_views` before `--apply`, retained until the deploy is confirmed good.
  - Verify: the dump restores into a scratch database and its row count matches production.

- [ ] **Orphan gate with a negative control**
  - Command: `NODE_ENV=production LEAGUE_DB_HOST=127.0.0.1 LEAGUE_DB_PORT=15432 node db/gates/check-saved-view-param-coverage.mjs --json`
  - Purpose: the oracle for a persisted key nothing rewrites, which drops a filter silently rather than erroring. It has produced a vacuous green before.
  - Verify: zero orphans naming the legacy key — AND, before trusting that, delete one live rename rule, re-run, confirm it reports, and restore the rule. Per `docs/guides/data.md`, that control is the only thing distinguishing "no orphans" from "cannot see orphans".

- [ ] **Population check across every surface**
  - Command: re-run the Background census, including the `where` lateral, after `--apply`
  - Verify: `has_single` is 0 on both `columns` and `where`; `has_multi` has risen by exactly the pre-run `has_single`; the view count is unchanged. Re-baseline immediately before the run — the population drifted during planning, so a figure from this document will fail on drift alone.

- [x] **Capture the short-link baseline** — done 2026-09-01, ahead of the week-migration deploy
  - File: `scratch/league/collapse-single-variant-column-param-keys/short-url-baseline.md`
  - Purpose: the week migration changes what these same links render through the same params, so a baseline taken after it would leave the comparison with two candidate causes. The owning session confirmed that migration is not deploying soon — two open operator decisions gate it — so waiting was not viable.
  - Result: 37 URLs, splitting into 28 that carry one week per column (no week axis) and 9 that carry eighteen weeks per column (all with a week axis). Every multi-valued instance sits on a week-axis view, matching the independent saved-view measurement, so no production short link is exposed to the arity change split to the sibling task.
  - Verify: the reproducing SQL and the recovery query for any full URL are recorded in the artifact.

- [ ] **Short-link regression against the baseline**
  - Purpose: the surface with no second chance, and the one this change newly touches.
  - Verify: for each hash in the baseline, load the URL and confirm the same week identifier set renders per column under the canonical key. An identifier count that DROPS is `resolve_single_identifier` truncating — the exact failure the v0-migration repair exists to prevent, and the reason this check reads counts rather than eyeballing the grid.

- [ ] **The reported defect, end to end**
  - Verify: under `yarn dev:live`, bulk-select the two projection and three game-prop columns from the original report, confirm ONE "NFL Week" control appears in the Shared section, set a week, and confirm every selected column takes it.

- [ ] **Deploy**
  - Command: `yarn preflight:deploy`, then `yarn deploy:all`
  - Verify: preflight clean; re-run the short-link regression against the deployed bundle rather than a local build.
