---
title: Make team rate-stat team attribution configurable (historical vs current)
type: task
status: completed
description: >-
  Add a per-column team_attribution param ('historical' default | 'current') selecting which team a
  team RATE stat attaches to, honored consistently across numerator, denominator, and both team rate
  types (per_game + per_team_play) via a minimal mode branch in each existing resolver. Historical =
  per-year team-of-record (player_year->team_year bridge); current = player.current_nfl_team. Full
  resolver consolidation deferred to █████████████████████████.
base_uri: user:task/league/data-views/make-team-rate-stat-attribution-configurable.md
created_at: '2026-06-21T00:25:54.104Z'
entity_id: 53301c56-0340-4ca8-bdc9-b3a559385122
finished_at: '2026-06-21T14:03:20.064Z'
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]]
  - relates [[█████████████████████-█████████-████-█████-███-████-████-████-█████████]]
  - relates [[user:task/league/data-views/fix-cte-dedupe-player-year-teams.md]]
  - relates [[user:task/league/data-views/fix-data-view-socket-cache-ttl-unit.md]]
  - relates [[user:task/league/data-views/extend-team-attribution-to-counting-columns.md]]
started_at: '2026-06-21T13:06:22.403Z'
tags:
  - user:tag/league-xo-football.md
updated_at: '2026-11-06T00:03:53.519Z'
visibility_analyzed_at: '2026-11-06T00:17:46.472Z'
---

# Implementation Plan: Make team rate-stat team attribution configurable (historical vs current)

## Overview

- Add a per-column param `team_attribution` (closed set: `'historical' | 'current'`, default `'historical'`) that controls which team a team-grained rate stat attaches to, honored identically across numerator, denominator, and both team rate types (`per_game` + `per_team_play`).
- `'historical'` (default) attributes a stat to the player's per-year team-of-record (the `player_year -> team_year` identity bridge, gamelogs-derived). `'current'` attributes it to `player.current_nfl_team` (forward-looking projection — a mover's NEW team's volume, e.g. a 2026 redraft view).
- Expected outcome: the post-fix-#1 historical default is preserved unchanged; a view author can opt a column into `'current'`. The consistency invariant — numerator and denominator ALWAYS resolve to the same team — holds by construction and cannot be broken by any param combination.
- Impact: view authors gain explicit control over a semantic that is currently implicit-and-fixed (every team rate stat is silently historical since league `55ebee3c`). This unblocks redraft / projection-style views without forking column definitions.
- Ordering: follows league `55ebee3c` (the `per_game` denominator-consistency fix, already shipped + deployed) and the now-merged rate-type work (`refactor-data-view-from-plays-measure-first-rate-types`, Completed; RACR/PACR `7a4bd5af` and week-split `91374b08` are merged ancestors of HEAD — there is NO live concurrent rate-type session). The one live sibling on this surface is `█████████████████████████` (In Progress), which owns retiring the hardcoded `player_year_teams` bridge and the rate-type plugin system; this plan deliberately does NOT restructure the resolver so it cannot collide with that migration — see Design. Relates to (does not fix) the `data_view` socket cache-TTL-unit bug.

## Background

- Two team-join resolvers exist today:
  - Passive shared resolver `libs-server/data-views/resolve-team-join-target.mjs`: pure `({ query_context, params })`. Precedence: `matchup_opponent_type` -> opponent CTE; `team_reference` (read as `data_view_options.team_reference ?? query_context.team_reference`) -> team-subject; `player_year_teams_cte_name` if a prior bridge registered it -> per-season team; default `player.current_nfl_team`. It only READS whether a bridge was applied; it does not apply one. Consumers: `apply_team_stats_join` (team-stats-from-plays JOIN path, COUNTING columns), the `select-string.mjs` year-offset-range correlated-subquery branch, AND `per_team_play` (which calls it inside its `leftJoin` closure; wired via `████████████████████████████████████████`, league `ae578757`).
  - Active resolver `resolve_team_join_target` in `libs-server/data-views/output-aggregator/aggregator-rate.mjs` (exported by `55ebee3c`): `({ query_context, params, source })`. Team-subject check is `query_context.row_grain_id === 'team'` -> `query_context.team_reference`. For the default player-cell case it ACTIVELY calls `apply_bridge(player_year -> team_year)` (idempotent) and returns `player_year_teams.team`. Consumers: the `per_game` numerator (in `aggregator-rate`) and, via re-export, the `per_game` denominator join in `rate-type-per-game.mjs`. Bridge application here happens OUTSIDE the `leftJoin` callback (fix #1 placed it there deliberately, since `apply_bridge` mutates `players_query`).
- `per_team_play` (`rate-type-per-team-play.mjs`) reaches historical mode by manually pre-applying the bridge (guarded on `query_context.identity_id?.startsWith('player')` and `!matchup_opponent_type`) ABOVE its `leftJoin`, then calling the passive (pure) resolver INSIDE the closure.
- Bridge idempotency: `identity-bridge-registry.mjs` `apply_bridge` keys on `"${from}->${to}|${mode}"` in `query_context.applied_bridges` and early-returns if present; `player-year-to-team-year.mjs` `add_cte` is idempotent via `query_context.registered_ctes` (per `fix-cte-dedupe-player-year-teams`). The bridge CTE materializes `player_year_teams` from `player_gamelogs` (team-of-record = max game-count per pid/year) for a `year_range` it resolves from `query_context.year_range` (set only under a year/week split) -> `params.year` -> `source.year_default` -> `current_season.year`. Because year context is per-column, the bridge is correctly applied at each call site with that column's context today; this plan does NOT change where the bridge is applied, so that per-column year correctness is preserved.
- Param-threading precedent: each plugin declares `consumes_params`; `consumed-params-signature.mjs` extracts only declared params into the CTE-name hash and cache key, so a new param must be added to every relevant `consumes_params` to (a) be readable at resolve time and (b) distinguish CTEs/cache keys between modes. There is no central param-validation throw; unknown values fall through branch-by-branch.
- Regression coverage from fix #1: `test/data-views.team-per-game-denominator.spec.mjs` asserts both halves project via the bridge (`"team_code" = "player_year_teams"."team"` numerator, `"team" = "player_year_teams"."team"` denominator) and neither uses `player.current_nfl_team`. Tests run on bundled PG16 via `yarn test:local <spec>` (local PG14 too old).

### Assumptions

- The bundled PG16 `yarn test:local` harness reproduces live SQL shape (checked: fix-#1's spec asserts on `query.toString()`).
- Adding the `'current'` mode branch BEFORE existing precedence in each resolver leaves all non-rate-type callers unchanged, because team-stats-from-plays and select-string columns do not declare `team_attribution` in `consumes_params`, so `params.team_attribution` is undefined -> normalizes to `'historical'` -> existing code path (checked: those columns' `consumes_params` do not include the new param; default-historical preserves today's behavior).
- Bridge application stays at its current per-call-site locations, so per-column `year_range` resolution is unchanged — no single up-front application that could freeze the bridge to one column's year window (this is why the rejected up-front-pass design is NOT used).
- Adding `team_attribution` to each relevant `consumes_params` is sufficient to vary CTE/cache keys between modes (checked: `consumed-params-signature.mjs` extracts only declared params); no separate registry edit needed.
- The cited rate-type commits (`7a4bd5af`, `91374b08`, `55ebee3c`, `ae578757`) are merged ancestors of HEAD with a clean tree (checked: `git merge-base --is-ancestor` true for all four); there is no live concurrent rate-type session to serialize against.

## Design

### Approach: minimal per-call-site mode branch (no resolver restructure)

The feature is fundamentally DATA — a single mode value selecting between two existing team expressions. The implementation adds that one branch to each of the two resolvers, leaving bridge application exactly where it is today (per call site, with per-column year context). This deliberately does NOT consolidate the two resolvers into one, does NOT add an up-front bridge-registration pass, and does NOT touch the query-assembly enumeration — all of which a prior draft proposed and a plan review refuted (no single column-enumeration hook exists; a hoisted bridge would freeze the CTE to one column's year window; an unconditional `withMaterialized` CTE is an evaluation fence, not free). Full resolver consolidation is the charter of the In-Progress `█████████████████████████` task; doing it here would collide with that work. v1 keeps the two resolvers and pins their parity with a test.

- Active resolver (`aggregator-rate.mjs` `resolve_team_join_target`): add an early branch — when `team_attribution === 'current'`, return `'player.current_nfl_team'` WITHOUT calling `apply_bridge`; otherwise the existing historical behavior (apply bridge, return `player_year_teams.team`) is unchanged. This single change covers BOTH the `per_game` numerator and the `per_game` denominator, because the denominator join in `rate-type-per-game.mjs` already calls this same exported resolver — the consistency invariant for `per_game` holds by construction.
- Passive resolver (`resolve-team-join-target.mjs`): add the same early branch — when `team_attribution === 'current'`, return `'player.current_nfl_team'` placed AFTER the matchup and team-subject checks (those take precedence regardless of attribution mode) and BEFORE the `player_year_teams_cte_name` shortcut. Placing it before the CTE-presence check closes a latent cross-column leak: a `'current'` column will not be dragged onto `player_year_teams.team` just because a sibling `'historical'` column registered the bridge. Historical / no-param behavior is unchanged.
- `per_team_play` (`rate-type-per-team-play.mjs`): gate the manual `apply_bridge` block on `team_attribution !== 'current'` (i.e. apply the bridge only in historical mode). The in-closure passive-resolver call then returns `current_nfl_team` for current mode via the branch above, and `player_year_teams.team` for historical. No structural change to the closure.
- Param as data: add `'team_attribution'` to `consumes_params` of `aggregator-rate.mjs`, `rate-type-per-game.mjs`, and `rate-type-per-team-play.mjs` (the two rate types only — see Scope). Array-unwrap and normalize at read time, default `'historical'`, mirroring the existing `matchup_opponent_type` handling.

The two resolvers carry the same two-line `'current'` branch (minor, intentional duplication). A parity test asserts both produce identical mode-driven results so they cannot drift; collapsing them into one function is left to the identity-model migration that owns that surface.

### Scope: two rate types only (v1)

`team_attribution` is confined to `per_game` and `per_team_play`. The goal is team RATE stats; team-stats-from-plays COUNTING columns and the select-string year-offset branch are explicitly OUT OF SCOPE for v1 — they are not re-touched, and because they do not declare the param their behavior is byte-for-byte unchanged. A prior draft expanded into them and would have shipped an undiagnosed historical-vs-current default flip to counting columns (and re-touched code the just-completed measure-first refactor rewrote). That expansion is deferred to a separate follow-up task, contingent on first MEASURING the current counting-column default — filed as a KB-updates item below.

### Edge cases (only the first two ship behavior; the rest are documentation)

- Free agent (`current_nfl_team='INA'`) + `'current'` -> blank both halves, consistently. A last-team fallback is explicitly OUT OF SCOPE (separate future decision; do not build).
- Rookie / no 2023-2025 gamelogs + `'historical'` -> blank numerator (no bridge row); `'current'` covers them.
- Year-split + `'current'` -> single snapshot repeated across year rows. Documented as well-defined projection behavior; NO hard-reject throw added (avoids a speculative error path; operator-overridable to hard-reject).

### Knowledge Base Impact

- No user-base ABOUT.md or entity-schema changes — this is league-repo code.
- Create a discrete league task entity for the `data_view` socket cache-TTL-unit bug (`api/sockets/data_view.mjs` ~line 159: `cache_ttl` computed in ms but passed to redis as seconds; `cache_expire_at` null in offseason -> cached results persist ~250 days) and add a `relates` edge from this task to it. SEPARATE from this work, not fixed here, but linked so the relation graph is self-describing (final live verification of this work depends on it).
- File a follow-up task entity for team-stats-from-plays / select-string counting-column attribution (the deferred scope), with a `relates` edge from this task.
- Document the `team_attribution` param wherever league enumerates data-view column params (param reference and/or `[[██████████████████████████████-████-███████]]`). Light-touch.
- No tag implications; no new guideline.

### Working tier / orchestrator shape

- No scratch slug needed — changes are confined to source files and a test spec; no multi-file artifacts.
- No orchestrator — bounded single task entity (one mode branch in each of two resolvers + one gated block + param threading + tests).

## Notes

- The consistency invariant is the acceptance bar, not the param. A test must assert that flipping `team_attribution` flips BOTH halves together for both rate types, and a parity test must assert the two resolvers' `'current'` branches agree so they cannot drift.
- Cache caveat that MUST be acted on at ship: because of the TTL-unit bug above, any user-visible value change from this work — the existing `historical` default OR a view flipped to `current` — will NOT surface until manual cache invalidation/warm. Plan a warm step for affected views.
- Coordination: before editing `aggregator-rate.mjs` / the `rate-type/` files, confirm with the In-Progress `█████████████████████████` task whether the resolver/bridge surface is mid-rewrite there; the minimal mode-branch approach is chosen specifically so the two efforts do not conflict, but a quick sync avoids a merge race.
- Debug workflow for live-view verification: `[[██████████████████████████████-████-███████]]` (`yarn debug:data-view /u/<hash>`). Repo `repository/active/league` (SSH host `league`); deploy `yarn deploy` AFTER push.

## Plan

### Coordination (gate — before any source edit)

- [x] **Sync with the In-Progress identity-model migration on resolver/bridge ownership**
  - Purpose: the live sibling on this surface is `█████████████████████████` (NOT a rate-type session — those commits are merged). Confirm `aggregator-rate.mjs`, `resolve-team-join-target.mjs`, and the `rate-type/` files are not mid-rewrite there, or agree on sequencing.
  - Verify: working tree clean for the target files (`git -C repository/active/league status --porcelain -- libs-server/data-views/resolve-team-join-target.mjs libs-server/data-views/output-aggregator/aggregator-rate.mjs libs-server/data-views/rate-type/`), and an explicit ack recorded as a task observation.

### Core Implementation

- [x] **Add the `'current'` mode branch to the active resolver**
  - File: `libs-server/data-views/output-aggregator/aggregator-rate.mjs` (`resolve_team_join_target`)
  - Purpose: cover both the `per_game` numerator and denominator (both call this exported resolver) with one change.
  - Changes: read + array-unwrap `params.team_attribution` (default `'historical'`, mirroring the `matchup_opponent_type` unwrap already in this function); after the matchup and `row_grain_id === 'team'` checks, add `if (team_attribution === 'current') return 'player.current_nfl_team'` BEFORE the `apply_bridge` call; leave the historical path untouched.
  - Verify: `yarn test:local test/data-views.team-per-game-denominator.spec.mjs` still passes (historical default unchanged).

- [x] **Add the same `'current'` branch to the passive resolver and close the cross-column leak**
  - File: `libs-server/data-views/resolve-team-join-target.mjs`
  - Purpose: honor the param for `per_team_play` (which calls this resolver) and prevent a `'current'` column inheriting a sibling's bridge.
  - Changes: read + array-unwrap `params.team_attribution`; insert `if (team_attribution === 'current') return 'player.current_nfl_team'` AFTER the matchup and `team_reference` checks and BEFORE the `player_year_teams_cte_name` shortcut; leave historical / no-param behavior unchanged.
  - Verify: cross-column non-leak test (below) passes; existing passive-resolver consumers (team-stats-from-plays, select-string) unchanged — confirm via `yarn test:local` on their existing specs.

- [x] **Gate the per_team_play manual bridge on historical mode**
  - File: `libs-server/data-views/rate-type/rate-type-per-team-play.mjs` (`join_per_team_play_cte`)
  - Purpose: in `'current'` mode, do not materialize/join the historical bridge; the in-closure resolver then returns `current_nfl_team`.
  - Changes: read + array-unwrap `params.team_attribution`; add `team_attribution !== 'current'` to the existing guard on the manual `apply_bridge` block; no change to the `leftJoin` closure.
  - Verify: `per_team_play` both-mode SQL-shape test (below) passes; `yarn debug:data-view /u/<team-grain-per_team_play-hash>` still composes valid SQL.

### Configuration & Setup

- [x] **Thread `team_attribution` into consumes_params (two rate types only)**
  - Files: `libs-server/data-views/output-aggregator/aggregator-rate.mjs`, `libs-server/data-views/rate-type/rate-type-per-game.mjs`, `libs-server/data-views/rate-type/rate-type-per-team-play.mjs`.
  - Purpose: make the param readable at resolve time and fold it into the CTE-name hash + cache key so historical and current columns never collide. Deliberately NOT added to team-stats-from-plays / select-string (out of scope).
  - Changes: add `'team_attribution'` to each of the three `consumes_params` arrays.
  - Verify: a test asserting two `per_game` columns differing ONLY in `team_attribution` produce distinct CTE names / cache signatures (via `consumed-params-signature.mjs`).

### Knowledge Base Updates

- [x] **File the data_view socket cache-TTL-unit bug as its own league task and link it**
  - Create a league task entity describing: `cache_ttl` computed in ms but passed to redis as seconds at `api/sockets/data_view.mjs` ~line 159; `cache_expire_at` null in offseason -> ~250-day stale cache; query/data fixes need manual invalidation/warm. Add a `relates` edge from this task to it.
  - Verify: `base relation forward <this-task-uri>` shows the edge; the new entity exists via `base entity get`.

- [x] **File the deferred counting-column attribution follow-up and link it**
  - Create a follow-up task entity for extending `team_attribution` (or equivalent) to team-stats-from-plays COUNTING columns and the select-string year-offset branch, gated on first measuring their current historical-vs-current default. Add a `relates` edge from this task.
  - Verify: `base relation forward <this-task-uri>` shows the edge; the new entity exists.

- [x] **Document the team_attribution param**
  - Add `team_attribution` (values, default, semantics, edge cases) to wherever league documents data-view column params and/or `[[██████████████████████████████-████-███████]]`.
  - Verify: the param name appears in the doc and renders correctly.

### Testing & Validation

- [x] **Consistency-invariant test (both rate types)**
  - File: add `test/data-views.team-attribution.spec.mjs`.
  - Assert: for `per_game` and `per_team_play`, numerator and denominator resolve to the SAME team expression in BOTH modes (historical -> both `player_year_teams.team`; current -> both `player.current_nfl_team`). Flipping the param flips both halves together.
  - Verify: `yarn test:local test/data-views.team-attribution.spec.mjs`.

- [x] **Resolver parity test (drift guard for the two resolvers)**
  - Assert: for the same `({ query_context, params })` inputs across mode/matchup/team-subject combinations, the active and passive resolvers return the same team expression — so the duplicated `'current'` branch cannot drift.
  - Verify: `yarn test:local test/data-views.team-attribution.spec.mjs`.

- [x] **Cross-column non-leak test**
  - Assert: a view with one `'historical'` and one `'current'` team rate column resolves them to DIFFERENT teams (the current column is NOT pulled onto `player_year_teams.team` by the sibling's bridge).
  - Verify: `yarn test:local test/data-views.team-attribution.spec.mjs`.

- [x] **Executed-result edge-case tests (population-level)**
  - Run executed queries (not just SQL shape) on the bundled PG16 fixture for: a team-changer (different value historical vs current), a free agent (`INA`) under `'current'` (blank both halves), and a year-split + `'current'` (same value repeated across year rows). Failure mode caught: a mode that silently collapses to the other, or a team-changer showing identical values across modes (would mean the param is not wired).
  - Verify: `yarn test:local test/data-views.team-attribution.spec.mjs` (executed-result assertions).

- [x] **Live verification + cache warm**
  - After deploy: re-run `yarn debug:data-view` on the reported view `/u/f9bfd706be9d7f4438195a671eb0353c` (must remain unchanged under the historical default) and on a purpose-built `'current'` view (mover shows NEW team's volume).
  - Because of the TTL-unit bug, manually invalidate/warm any affected cached view so the value change surfaces.
  - Verify: live values match expectations; warmed view reflects the new attribution.
