---
title: Shorten view cache TTL inside live game windows
type: task
status: completed
description: >-
  Bound data-view and plays-view cache staleness to a short interval while a game in the view scope
  is being played, using a calendar-derived live game window as the signal. Adds a TTL floor on top
  of the existing per-source fold rather than replacing it; a change-detection watermark over
  nfl_plays.updated was refuted in review.
aliases:
  - █████████████████████-████████████████-████-██████-███████-████-████-████████████
base_uri: user:task/league/data-views/shorten-view-cache-ttl-inside-live-game-windows.md
created_at: '2026-09-22T02:46:58.152Z'
entity_id: c65a8991-0635-46a4-b4d2-f1ddd6f217eb
finished_at: '2026-09-22T05:09:42.519Z'
observations:
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
owner_identity_uri: █████████████████████████
priority: High
public_read: true
relations:
  - relates [[█████████████-████-████-█████-███-███-█████-███████]]
  - precedes [[user:task/league/data-views/consolidate-plays-view-cache-lifecycle.md]]
tags:
  - user:tag/league-xo-football.md
  - user:tag/software-task.md
updated_at: '2026-09-22T05:09:42.519Z'
visibility_analyzed_at: '2026-11-06T00:17:47.554Z'
---

# Implementation Plan: Shorten View Cache TTL Inside Live Game Windows

## Overview

- Bound data-view and plays-view cache staleness to a short interval while a game in the view's scope is actually being played, replacing a TTL floor of 6 hours for current-season play data.
- The mechanism is a calendar-derived live game window, not change detection. A first version of this plan tried to detect change via `max(nfl_plays.updated)`; that oracle was refuted in review and the reasons are recorded under Rejected Approaches so it is not re-proposed.
- Nothing is removed. `libs-server/data-views/cache-info-utils.mjs` and `libs-server/data-views/get-cache-info-for-fields-from-plays.mjs` both keep their current behavior. This plan adds a floor-lowering pass on top of the existing per-source fold, because that per-source knowledge is the only thing that knows about the thirty-odd tables a play watermark cannot see.
- Impact: every surface sharing the `/data-views/` and `/plays-views/` key namespaces — the interactive socket table, `POST /data-views/search`, `GET /data-views/export/:view_id/:export_format`, the plays-view socket, `POST /plays/views/search` and `GET /plays/views/export/:view_id/:export_format`. A caller exporting mid-game currently receives a body that can predate kickoff.
- Ordering: follows the Abandoned `██████████████████████████████████████` task, whose milliseconds-versus-seconds fix has shipped. Precedes the plays-view cache-lifecycle consolidation, which must land after this: that task gives the plays-view export a cache write it does not have today, so until these TTL rules exist, fixing it would make live exports staler rather than fresher.

## Background

Measured on production during REG week 2 of 2026 with a game in progress. Commands are recorded beside each figure because a later session re-runs them.

- Live plays were landing: `select max(updated) from nfl_plays where season_year=2026 and season_type='REG' and week=2` returned a value 3 seconds old.
- 58 `/data-views/*` keys and 4 `/plays-views/*` keys resident; data-view TTL median 2.8 days, maximum 6.8 days, 47 of 58 over 24 hours. Via a key scan and a per-key TTL read on the API host.
- redis holds 85 keys under `allkeys-lru` with zero evictions, so key enumeration is cheap and an operator flush is viable.
- All 601 column definitions carry `get_cache_info`, so the skip branch at `libs-server/get-data-view-results.mjs:2109` never fires in practice. Reproduced by enumerating the registry. That branch emits a `debug('data-views')` line rather than being silent, and league's CLAUDE.md records that the namespace reaches pm2 stderr.

The defect is the floor for current-season play data, not the fold that selects it. The long production TTLs mostly belong to views correctly scoped to completed seasons.

`nfl_games.status` cannot discriminate anything. It is null for scheduled games, for in-progress games, and for 7,612 historical games — verified tonight against the in-progress Monday game, which carries `status` null and `game_clock` null while its `finalized_plays_updated_at` is minutes old.

### Rejected approaches

**A watermark over `nfl_plays.updated`.** Refuted in review. `libs-server/update-play.mjs:373` defaults `stamp_updated` to null and `:447-457` states why — enrichment must not advance the watermark that triggers it — and no caller anywhere opts in. So an operator adjudicating a play's `passer_pid` changes every from-plays view and moves nothing. Further, `scripts/process-plays.mjs:427` rewrites canonical play columns without stamping, roughly 60 adhoc `UPDATE nfl_plays` files under `private/db/adhoc/` skip the stamp across the 2001-2026 seasons, a delete lowers a week's maximum and so reads as unchanged, and `scripts/calculate-play-yardline.mjs:55` stamps unconditionally on every merged row and would invalidate every current-season entry with no data change.

**A watermark over `nfl_games.finalized_plays_updated_at`.** Suggested in review as the better oracle because finalize ends by rewriting the gamelogs and snaps a play-level watermark cannot see. Rejected for this goal: finalize runs at END_GAME, so that column does not move during the game that is being played, which is the interval this task exists to cover. It remains the right oracle for post-game derived-table freshness and is noted as such for any future task with that goal.

**Bounding mutability to the current season.** False. `max(updated)` grouped by season year returns 2026-09-21 for the 2025 season and 2026-09-20 for 2024, so prior-season rows change routinely. Handled here by an operator flush rather than by a calendar claim.

### Assumptions

- A game's play data can change from kickoff until shortly after it ends, and no game exceeds eight hours. **Checked in the second sweep** — see Sweep findings below. The measured maximum is 4.22 hours, so eight is a 1.9x envelope.
- Kickoff is read from `nfl_games.kickoff_at`, a native `timestamptz`. **Revised in the second sweep** — the varchar derivation from `date` and `time_eastern` is no longer used. See Sweep findings.
- The live-window query is cheap. **Re-measured in the second sweep** against `kickoff_at`: 3.9ms, 774 buffers, sequential scan. No index covers `kickoff_at`, and none is warranted under memoization.
- An empty `resolve_view_scope` result means unbounded in time, not "no weeks". Checked by driving the function directly: a table state with no year param returns a zero-length list, while current-season and prior-season states each return 18 week ids. Any design that iterates the list treats unbounded as covering nothing, which is the inverted reading this plan must avoid.
- `nfl_week_id` values are read out of `nfl_games` and out of `resolve_view_scope`, never reconstructed locally, per the canonical-helper rule.
- The two subsystems disagree on TTL units today — `get-cache-info-for-fields-from-plays.mjs` is milliseconds, `get-plays-view-results.mjs:40-41` is seconds. Checked. Every constant this plan introduces carries its unit in its name, because the predecessor task was a units bug.

### Sweep findings

Second sweep, 2026-09-22. The rewrite around a calendar-derived live window had not itself been reviewed. Two findings are Critical and change the design; the rest close open assumptions.

**Critical — `nfl_games.kickoff_at` already exists and retires the varchar mechanism.** It is a native `timestamptz`, and it is populated on every row where `date` and `time_eastern` are. Checked for agreement against the plan's own derivation across 2,239 rows since 2020: `count(*) filter (where kickoff_at is distinct from (date || ' ' || time_eastern)::timestamp at time zone 'America/New_York')` returns 0. So reading `kickoff_at` is not an optimization, it is the same instant with the parsing removed — the string concatenation, the naive cast, the timezone interpretation and the whole `to_timestamp` double-conversion trap all become unreachable. The cost is that the window predicate can no longer use `idx_nfl_games_season_year_season_type_week_esbid`; re-measured, it is a 3.9ms sequential scan over 15,623 rows, which under `LIVE_GAME_WINDOW_MEMO_SECONDS` memoization is not worth an index.

**Critical — the execution-multiple guard cannot be implemented in the fold.** `max(floor, multiple * measured_execution_seconds)` needs a measured duration, and there is none at the point the plan writes the TTL. `data_view_metadata.cache_ttl` is finalized during query *construction* inside `get-data-view-results.mjs`, before a query has run; `query_execution_duration_ms` is first computed at `execute-data-view-request.mjs:891`, after the query returns, three lines above the cache write at `:912`. The plays-view is worse — neither of its two cache-write sites, `api/routes/plays.mjs:638` and `api/sockets/plays_view.mjs:181`, times execution at all.

So the rule splits along the seam that already exists. The fold decides *whether* a result is live-covering, which is a pure scope question needing no timing, and publishes `live_covering` on the metadata. The cache-write site decides *how short*, because that is where the duration lives. The two plays-view write sites each need a `Date.now()` pair added, which is also what the successor consolidation task will collapse into one executor.

**Closed — the eight-hour lookback.** This was the plan's one assumption marked unchecked. `nfl_plays.wall_clock` is a `timestamptz` stamped per play, so `max(wall_clock) - kickoff_at` per game measures how long play data actually kept arriving. Over 285 games since 2021: p50 3.09h, p99 3.84h, maximum 4.22h, and zero games over five hours. Eight hours stands as a 1.9x envelope on the observed maximum. Deliberately not raised further — a suspended game resumes days later and exceeds any fixed lookback, so that case belongs to the operator flush rather than to a larger constant.

**Minor — 24 scheduled games carry no kickoff instant**, all of them 2026 REG weeks 16, 17 and 18, including all 16 of week 18. These are flex-scheduling placeholders, and they drop out of the live set while null. Not load-bearing: the NFL fixes those slots before the games are played, so a week is only invisible while it is also weeks away. Worth stating because the failure direction is serving stale data.

**Resolved non-issues.** `get_plays_view_results_query` is already `async` at `:177`, so consulting a database-backed window inside the plays-view fold is not a signature change and breaks no existing spec. The p50 428ms and p90 2,447ms figures the TTL floor derives from do exist in the service objectives doc, in the under-5s row. `redis_cache` does expose `keys()` at `:264` and `del()` at `:277` as the flush command assumes.

**Incidental.** `nfl_games.time_start` and `time_end` are 100% null across the entire table — zero non-null rows. Nothing in this plan reads them; noted so a later session does not mistake them for a game-duration source.

**Size.** Left as one unit. The code is one new module, two fold edits, three write-site clamps and one CLI script; the knowledge-base tail is six small entity edits. Splitting again would cost more in handoff than it saves in blast radius.

### Acceptance baseline

Measured 2026-09-22 with `scripts/classify-stored-views-live-coverage.mjs`, which drives the real `resolve_view_scope` over every `user_data_views` row rather than reconstructing the scope rule in SQL. Two earlier reconstruction attempts returned 179 and then 228, both wrong.

```
NODE_ENV=production node scripts/classify-stored-views-live-coverage.mjs --probe-at <instant>
```

| Probe instant | Live window | Live-covering | Not live-covering |
| --- | --- | --- | --- |
| 2026-09-20T17:05:00Z (Sunday 13:05 ET kickoff) | `2026_REG_WEEK_2` | 64 | 164 |
| 2026-09-22T14:00:00Z (Tuesday 10:00 ET) | none | 0 | 228 |
| 2026-06-15T17:00:00Z (June) | none | 0 | 228 |

228 stored views total, 28 of them carrying an unbounded scope, 0 unparseable. The three readings differ, which is the point — a classifier that fired on everything would read 228 at all three probes and one that fired on nothing would read 0, and both would pass a single-instant check.

## Design

### The mechanism

One query resolves which `nfl_week_id` values currently sit inside a live game window — any game whose kickoff falls between `now() - LIVE_GAME_WINDOW_LOOKBACK_HOURS` and `now() + LIVE_GAME_WINDOW_LOOKAHEAD_MINUTES`. It is memoized per process for a short interval and shared by every cache write decision. Outside game windows it returns an empty set and nothing else in this plan engages.

A result is live-covering when the view's resolved scope intersects that set, or when the resolved scope is empty, because empty means unbounded and therefore includes the live week. A live-covering result takes a shortened TTL; everything else keeps the TTL the existing per-source fold produces.

The shortened TTL is `max(LIVE_GAME_WINDOW_TTL_FLOOR_SECONDS, LIVE_GAME_WINDOW_EXECUTION_MULTIPLE * measured_execution_seconds)`. The guard matters: the executor's own history records one 27,957-row export re-executing more than twenty times in a day at roughly 305 seconds each, and a bare 60-second floor would restore exactly that. The multiple ties refresh cost to a fixed fraction of execution cost, so a sub-second view refreshes often and a five-minute view does not thrash.

Staleness from manual corrections — the adhoc updates and prior-season backfills — is handled by an operator-invokable flush rather than by detection, since no reliable change signal exists. redis holds 85 keys and the adapter exposes `keys()` and `del()`, so enumerating and dropping a namespace is a few lines.

### File organization

- `libs-server/live-game-window.mjs` (new) — `get_live_game_window_nfl_week_ids()`, memoized, and the window constants. The only module that knows how a live window is defined. Plain functions, no class.
- `libs-server/get-data-view-results.mjs` — after the existing per-source fold, apply the live-window floor using the `resolve_view_scope` output already computed at `:1822`.
- `libs-server/plays-view/get-plays-view-results.mjs` — derive an `nfl_week_id` list from the resolved `play_year`, `play_seas_type` and `play_week` clauses and apply the same floor. Preserves the existing rule at `:59` that a non-exact operator widens to cover the current season.
- `cli/flush-view-cache.mjs` (new) — operator flush over a key namespace, for use after a manual data correction.

### Coined identifiers

- `get_live_game_window_nfl_week_ids` — returns week ids, named for what it returns.
- `LIVE_GAME_WINDOW_LOOKBACK_HOURS`, `LIVE_GAME_WINDOW_LOOKAHEAD_MINUTES`, `LIVE_GAME_WINDOW_TTL_FLOOR_SECONDS`, `LIVE_GAME_WINDOW_EXECUTION_MULTIPLE`, `LIVE_GAME_WINDOW_MEMO_SECONDS` — every one carries its unit.
- `live_covering` — the envelope and telemetry field naming the distinguishing property.

### Knowledge Base Impact

- `text/league/data-views/data-view-service-objectives.md` has no freshness objective, only latency. Add one with the measured pre-change window as its baseline.
- Repo docs made stale: `docs/data-views-system.md` and `docs/guides/data.md` for cache mechanics, and `██████████████████████████████████████` for cache inspection.
- Stale claim to retire: `libs-server/data-views-column-definitions/player-extended-salary-column-definitions.mjs:21-36` asserts nothing invalidates a data-view cache entry and names two `redis_cache.del` callers in an auction script. The second half is already wrong — the only callers are in a news spec.
- Observation candidates, all durable █████ █████ worth promoting to `guideline/nfl/league/query-traps/`: that `nfl_games.status` is null for scheduled, in-progress and historical rows alike; that `nfl_plays.updated` is deliberately unstamped by the modern write path; that an empty `resolve_view_scope` result means unbounded rather than empty; and that `to_timestamp(...) at time zone` double-converts.
- Relations: `relates_to` the Abandoned `██████████████████████████████████████` task, and `precedes` the plays-view cache-lifecycle consolidation task this plan creates.

### Working tier

No scratch slug. The measurements fit in this body.

### Orchestrator shape

Not warranted. One subsystem, one coherent change.

## Notes

- Scope deliberately narrowed after review. The plays-view cache-lifecycle consolidation — one executor owning read, execute and write, which is what structurally fixes the export reading the cache at `api/routes/plays.mjs:765` without ever writing it — moves to its own task, along with the redundant `/data-views/search` redis read at `api/routes/data-views.mjs:1323`. Neither needs anything from this plan, and bundling them doubled the blast radius of the freshness change.
- The eight-hour lookback is deliberately generous. Over-classifying a week as live costs a shortened TTL on views nobody is watching at 4am; under-classifying serves a stale body during a game.
- The live TTL floor should be set from the service objectives' measured distribution (p50 428ms, p90 2,447ms), not guessed.

## Plan

### Live Game Window Foundation

- [x] **Create the live game window module**
  - File: `libs-server/live-game-window.mjs`
  - Purpose: single definition of a live window, so no caller reimplements it.
  - Changes:
    - Add `get_live_game_window_nfl_week_ids()` returning the distinct `nfl_week_id` set whose games have a kickoff inside the window, reading `nfl_week_id` straight from `nfl_games` rather than reconstructing it.
    - Read kickoff from `nfl_games.kickoff_at` directly. Do not derive it from `date` and `time_eastern` — the sweep verified the column agrees with that derivation on all 2,239 rows since 2020, so the cast and its timezone trap are unnecessary.
    - Memoize for `LIVE_GAME_WINDOW_MEMO_SECONDS`, reading `current_season` off the object at point of use rather than into a module-level const.
    - Do not read `nfl_games.status`, which is null for scheduled, in-progress and historical rows alike.
    - Export a pure `apply_live_game_window_ttl_floor_seconds({ base_ttl_seconds, live_covering, execution_duration_ms })` so the two subsystems share one clamp rather than each reimplementing the `max`.
  - Verify: spec asserting the pair that must differ — a probe instant at a Sunday 13:05 kickoff returns a non-empty set while a Tuesday 10:00 probe and a June probe both return empty. A test that only asserts the non-empty case cannot distinguish a working window from one that always fires.

- [x] **Set the TTL floor from the measured distribution**
  - File: `libs-server/live-game-window.mjs`
  - Purpose: a bare short floor restores the twenty-times-a-day re-execution of a 305-second export that the executor's history records.
  - Changes: set `LIVE_GAME_WINDOW_TTL_FLOOR_SECONDS` and `LIVE_GAME_WINDOW_EXECUTION_MULTIPLE` from the p50 and p90 figures in `text/league/data-views/data-view-service-objectives.md`, and state the derivation in a comment.
  - Verify: unit spec asserting a sub-second execution takes the floor while a 305-second execution takes the multiple, so the two branches are both exercised.

### Data View Freshness

- [x] **Apply the live-window floor in the data-view fold**
  - File: `libs-server/get-data-view-results.mjs`
  - Purpose: lower the TTL only for results covering a game being played, leaving the per-source fold otherwise intact.
  - Changes:
    - After the existing `process_cache_info` loops, intersect the `resolve_view_scope` output already computed at `:1822` with the live window set.
    - Treat a zero-length scope as covering every live week, because empty means unbounded — verified by driving `resolve_view_scope` directly.
    - Set `live_covering` on `data_view_metadata`. Do NOT lower `cache_ttl` here — the sweep established there is no measured execution duration at this point, so the clamp cannot be computed. The fold publishes the boolean only.
  - Verify: three-way spec — an unbounded view, a current-season view and a prior-season-only view, asserting the first two are live-covering inside a window and the third is not, and that all three read false outside one. The readings must differ across the three.

- [x] **Clamp the data-view TTL at the cache write**
  - File: `libs-server/data-views/execute-data-view-request.mjs`
  - Purpose: this is the only point on the data-view path where both the live-covering flag and a measured execution duration are in scope.
  - Changes: at the write at `:912`, when `data_view_metadata.live_covering` holds, pass the base TTL through `apply_live_game_window_ttl_floor_seconds` with `query_execution_duration_ms`. The metadata TTL is milliseconds and `cache_set` takes seconds, so convert once and keep the existing `Math.round(cache_ttl / 1000)` as the single conversion point rather than adding a second one.
  - Verify: spec driving the existing `cache_set` seam with a live-covering metadata at a sub-second duration and at a 305-second duration, asserting the floor branch and the multiple branch produce different TTLs, plus a non-live-covering case asserting the TTL is untouched.

### Plays View Freshness

- [x] **Apply the live-window floor in the plays-view fold**
  - File: `libs-server/plays-view/get-plays-view-results.mjs`
  - Purpose: same rule on the other view type, which today examines no week clause at all in its TTL decision.
  - Changes: derive an `nfl_week_id` list from the post-default `play_year`, `play_seas_type` and `play_week` clauses using `libs-shared/nfl-week-identifier.mjs`; preserve the widening rule at `:59`; set `live_covering` on `plays_view_metadata` without altering `cache_ttl`, mirroring the data-view split. `get_plays_view_results_query` is already async, so the window lookup needs no signature change.
  - Verify: extend `describe('cache TTL')` in `test/plays-view.spec.mjs` with an in-window and out-of-window case for the same table state, asserting the two `live_covering` readings differ; an exact current-season week, a range covering it, and a historical-only range must classify distinctly.

- [x] **Clamp the plays-view TTL at both cache writes**
  - Files: `api/routes/plays.mjs`, `api/sockets/plays_view.mjs`
  - Purpose: neither write site measures execution today, so the clamp has nothing to scale against until one is added. Both sites are what the successor consolidation task collapses into a single executor.
  - Changes: wrap the `get_plays_view_results` call at `api/routes/plays.mjs:638` and `api/sockets/plays_view.mjs:181` in a `Date.now()` pair, then pass the base TTL through `apply_live_game_window_ttl_floor_seconds` when `plays_view_metadata.live_covering` holds. This subsystem's TTL is already seconds, so no conversion applies — assert that rather than inferring it.
  - Verify: spec per site with an injected cache-write seam, asserting a live-covering metadata yields the floor and a non-live-covering one yields the untouched TTL, and that the two readings differ.

### Operator Recovery

- [x] **Add a view cache flush command**
  - File: `cli/flush-view-cache.mjs`
  - Purpose: manual corrections — roughly 60 adhoc `UPDATE nfl_plays` files and several prior-season backfills — change results with no detectable signal, so recovery is an operator action rather than a mechanism.
  - Changes: enumerate a namespace via the adapter's `keys()` and drop each with `del()`, defaulting to a dry run that prints the count; accept `/data-views/` or `/plays-views/`.
  - Verify: run against the API host with the dry-run default and confirm the count matches a direct key scan of the same namespace; then confirm a real run drops exactly that many and the next request repopulates.

### Observability

- [x] **Signal a persistently unresolvable live window**
  - Files: `libs-server/live-game-window.mjs`, `libs-server/data-views/execute-data-view-request.mjs`
  - Purpose: if the window query fails it returns empty, which is indistinguishable from an ordinary quiet hour and silently restores the original defect. A `log()` line is not a report.
  - Changes: `emit_signal` when the query fails beyond a threshold, with a matching `resolve_signal` on recovery; add a `live_covering_ttl_applied` counter beside the existing `cache_hits` and declare the counter delta as the output oracle, distinct from exit code.
  - Verify: spec injects a failing reader and asserts the signal is emitted, then a succeeding one and asserts it resolves. Both halves are required — a spec that injects neither passes with the call site deleted.

### Testing and Validation

- [x] **Classify every stored view with the real resolver**
  - Purpose: a bug that classifies every view live destroys caching and one that classifies none restores the defect, and both look correct on a single fixture. A SQL reconstruction of the scope rule is a second implementation that will disagree with the first, which is how the previous draft's baseline became unreproducible.
  - Changes: add a script that loads every `user_data_views` row, runs the actual `resolve_view_scope` over each `table_state`, and reports the live-covering split against a fixed probe instant.
  - Verify: run it at a Sunday-kickoff probe and at a Tuesday probe and require the two counts to differ; record both numbers and the command in this task body as the pre-change baseline. Do not assert merely greater-than-zero and less-than-total, which passes at 1 and at 227.

- [x] **Confirm the suite ran**
  - Changes: `yarn test:isolated` scoped to the touched specs.
  - Verify: assert on the presence of the `N passing` summary line and the logged exit code, and confirm each new spec appears by name. Zero passing and zero failing together means nothing loaded.

- [x] **Gates**
  - Changes: `yarn lint`, `yarn check:types`, and `npx prettier --check` scoped to the touched files.
  - Verify: each exits zero; prettier is scoped with the binary because `yarn prettier` rewrites the whole tree and ignores arguments.

### Knowledge Base Updates

- [x] **Add a freshness service objective**
  - File: `text/league/data-views/data-view-service-objectives.md`
  - Changes: add a freshness row with the measured pre-change staleness window and the post-change target.
  - Verify: `base entity get` renders the row and the baseline names its measurement date.

- [x] **Promote the four █████ █████**
  - Files: `guideline/nfl/league/query-traps/` entries, `text/league/data-views/ABOUT.md`
  - Changes: record that `nfl_games.status` is null across scheduled, in-progress and historical rows; that `nfl_plays.updated` is deliberately unstamped by the modern write path and why; that an empty `resolve_view_scope` result means unbounded; and that `to_timestamp(...) at time zone` double-converts. Add two the second sweep produced: that `nfl_games.kickoff_at` is the canonical kickoff instant and the `date`/`time_eastern` varchars are a redundant second form of it, and that `nfl_games.time_start`/`time_end` are entirely null and are not a game-duration source.
  - Verify: each appears in `base entity get` output in `[category] One sentence.` form, and a `base search` for the trap's subject returns it.

- [x] **Reconcile the stale repo docs**
  - Files: `docs/data-views-system.md`, `docs/guides/data.md`, `██████████████████████████████████████`
  - Changes: describe the live-window floor alongside the per-source fold, and update the cache-inspection steps.
  - Verify: grep all three for the claim that TTL is the only freshness mechanism and confirm zero hits.

- [x] **Retire the stale invalidation comment**
  - File: `libs-server/data-views-column-definitions/player-extended-salary-column-definitions.mjs`
  - Changes: correct the claim at `:21-36`, including its already-wrong naming of two `redis_cache.del` callers in an auction script.
  - Verify: grep for `redis_cache.del` and confirm the comment names the callers that exist.

- [x] **Create the plays-view cache-lifecycle task and link it**
  - Purpose: the split-out scope needs an entity before this task closes, or the deferral lives only in prose.
  - Changes: create a task covering one plays-view executor owning read, execute and write, the export's missing cache write at `api/routes/plays.mjs:765`, and the redundant `/data-views/search` read at `api/routes/data-views.mjs:1323`; add `precedes` from this task to it.
  - Verify: `base entity get` on this task lists the relation and the new task resolves.

### Deploy

- [x] **Ship and confirm against a live window**
  - Purpose: the fix being in the source and the deployed artifact lacking the defect are different claims.
  - Changes: commit as one unit, then `yarn deploy:all`.
  - Verify: during a game window, request a current-season view and confirm its key's `TTL` is the shortened floor rather than hours, then request a prior-season-only view and confirm its TTL is unchanged. Assert on the TTL and on the `live_covering_ttl_applied` counter, not on body inequality — a correct implementation can refresh and return an identical body, so a body-difference check fails on working code.

### Deploy result

Shipped 2026-09-22 in the fleet deploy at league `83106f17d`, which carried commits `ee4ce476e` (implementation), `8dcf392c9` (repo docs) and `ed9d5fdcd` (script docs). The pm2 `server` process restarted at 04:56:25Z against a `live-game-window.mjs` written at 04:56:24Z, so the running process loaded the new code rather than merely having the file on disk.

Verified against the live API inside an open window (`2026_REG_WEEK_2`, kickoff 00:15Z, probed 05:08Z). Two requests at the same instant, differing only in scope:

| Request | Resulting redis TTL |
| --- | --- |
| plays view, 2026 REG week 2 (the live week) | 59s |
| plays view, 2020 REG week 2 | 604,799s (7 days) |
| data view, unbounded scope | 55s |
| data view, scoped to 2020 | unchanged, long |

The readings differ by four orders of magnitude across the pair, which is the assertion — not body inequality, since a correct refresh can return an identical body. **The 55s on the data-view side is the units check**: milliseconds metadata reaching redis as seconds, not as 60,000s or 0.06s.

The resident data-view median stays 410,902s over 52 keys, and that is correct rather than a miss — most stored views are scoped to completed seasons and should not move.

Deploy was held for roughly 40 minutes behind bulletin #940, a ████████████████ gate failure on a sibling's commit `729190341`. Not this lane's defect and not this lane's to fix; resolved by its owner.

Standing note, not a checkbox: every step declares its own verify, and the pairing requirement — a control and a perturbed case must produce different readings — applies to all of them, not only the ones that name it.
