Releases
1.9.7
For you
- Rankings no longer include a player who has been cut, placed on reserve, moved to the practice squad, or announced retired, though a player who is simply inactive for one game day still shows up, and a player just signed mid season with no roster history yet still shows up too. The track record now grades each game forecast on the call that stood at kickoff, the last one we published before the game started, and grades each player projection on the first one we published for that week, where player projections used to score nothing at all. And comparing two players who share a short name now returns every one of them, not a guess.
- Nothing changes for you yet. The server that lets assistant tools read these forecasts now updates itself whenever a fix ships, instead of waiting for someone to redeploy it by hand.
- Nothing changes for you. This is an internal step that keeps one current designation per player alongside the database that already records every sighting; nothing reads it yet.
- Nothing changes for you. The designation record kept by that same internal step now has a reader: the live score worker merges it into the game snapshot it already serves, both on its own schedule and on every request, so the two pieces are connected even though no page shows any of it yet.
Under the hood
- Every place the pipeline decides who is eligible for the current season, cold start, in season, and the current slate, plus the projected season rookie list, now excludes a player whose latest seasonal roster row carries a denied status: cut, reserve, practice squad, or retired. A 2025 backtest across weeks 2 through 18 found 6 of 522 reserve status player weeks and 1 of 229 practice squad player weeks scored 10 or more points, against 39 of 682 game day inactive weeks, so inactive stays eligible and any status the backtest never saw defaults to eligible too, on the theory that dropping a real starter is the worse mistake. Player scoring is fixed at the storage layer: the container previously restored only the prior run's files, so the current run's own published projections were never on disk to score and reported zero calls scored every time; fetch_published now restores every published run of the season, for the run kinds that write a track record, never scores the current run against itself, and keeps the previous track record instead of publishing a smaller one when a restore comes back incomplete. With the whole season on disk, the old earliest publication rule for games would have graded every game on the season opener's forecast, because every run prices every remaining game, so a game is now graded on the latest card published before its kickoff, falling back to its earliest card only when none came before kickoff; a player projection is still graded on its first publication of the week. The MCP compare tool's name lookup, which used to take the first match in points order with no signal that a second one existed, now returns every candidate with an ambiguous flag plus each one's team and position when a short name is not unique.
- The MCP server Worker was never part of the automatic deploy, so it had not been redeployed since 2026-09-09 and still ignored the paging fix merged the next day. A new deploy job ships it after every green gate on dev, supplying its two Access settings from repository variables and refusing to deploy if either is missing, because the committed config leaves them empty on purpose so an unconfigured deploy fails closed rather than locking the live server out.
- The scheduled injury worker now keeps a reconciled answer next to the raw history it already writes: one current designation per player and fact type, maintained in the same pass rather than rederived on every request later. A superseded designation keeps its row, carrying both the moment it was first seen and the moment it was replaced, because rendering a correction depends on comparing those two. A cap retires a designation nobody has restated in seven days, since going quiet about it is not itself a contradiction and would otherwise display forever. The moment a designation was first seen is written once and never moved when the source restates that same designation later, because how long a value has been visible is exactly what separates a correction worth showing from a quick self correction. A player whose report time cannot be read, or is dated impossibly far ahead, which is what a source with a skewed clock produces, is reported loudly now instead of quietly dropping out of the answer while every other signal from that run reads as healthy.
- The live score worker now merges that reconciled designation record into the scores snapshot it was already writing on its own schedule, rather than a second process touching the same file; both read routes for that snapshot now do the same database join at request time too, which covers the much larger stretch of a week when the scheduled merge sits idle because there is no game in progress. A failed database read on the scheduled path carries the previous snapshot's designations forward per game instead of replacing them with an empty list, since an empty list claims nobody is hurt and a failed read claims nothing at all; the request path instead serves the snapshot exactly as it finds it rather than failing the request. A source club code that matches nothing in the schedule is named in the log rather than silently dropped, because the visible failure mode is a club whose designations read as empty, the same shape as a club with nothing to report. The worker's access to those database tables stays read only, now checked by one guard that scans every statement actually executed across both the scheduled path and the request path, replacing an earlier pair of narrower checks that a reviewer's own smuggled write got past. Documented in a new runbook, `docs/runbooks/LIVE-FACTS.md`, with every interval in it traced to the constant in code that sets it. A review round then closed five more silent failures in the same join. A designation now lands on the game a club is actually playing, or the next one it plays, measured against the clock: the earlier rule picked the earliest game not yet marked final, which meant a postponed or never recorded game from an earlier week quietly took every designation for the rest of the season while the game being played read as empty. A snapshot that does not say which clubs are playing, which is exactly what the file sitting in storage looks like until the first scheduled merge after this ships, is now served untouched and reported, rather than being stamped with an empty designation list on every game, which would have claimed nobody in the league was hurt. A read that returns rows and attaches none of them is reported too, on both paths, because until now that case read as healthy on every signal there was. Each snapshot carries the time of the last read that actually reached the database, so a stretch of carried forward designations during an outage is visible rather than looking minute by minute like a fresh answer. An attached designation carries the club code the schedule uses, keeping the source's own spelling beside it, the same correction the 1.9.x entries below record for the pregame join. And a request no longer reads every row in the table: it is skipped entirely while the stored snapshot is newer than a short interval, and the single game route asks only for the two clubs in that game. That interval first traced to how often the site itself polls, which answers a different question than the one it was being used for; it now traces to how often the worker's own background check runs instead, with a cushion for one missed cycle, so a caller reading this route directly is judged by whether a check actually just ran rather than by a completely unrelated reader's habits. Separately, the guard proving this worker never writes to a table it only reads had a blind spot on the socket path: an env with nothing to check passed as though it had checked and found nothing, which are not the same claim. It now refuses to pass at all when it has nothing to check, and the socket path was given something to check so it no longer hits that refusal. A further round closed three more. That same guard matched a table name only in lower case, while the database treats a table name as the same table whichever case it is written in, so a delete spelled in capitals was a real delete that no guard so much as looked at; all three checks now ask one shared question that ignores case. The guard's own header now states what it is: a tripwire, not a sandbox. A green run says nobody wrote the known shape on a path a test drives, and it says nothing about a write on a path no test drives or a write deferred until after the check has run. The rule deciding which game a designation lands on was still letting a game that never reached a final take the whole season. Reading it as the club's latest kickoff so far meant that on any day between games, a postponed game from week one was the only kickoff behind us and absorbed everything. A game now loses that claim once the club has kicked off a later one, because a club cannot start a second game without finishing the first, and where a club has both an unfinished past game and a scheduled next one, the nearer of the two in time holds the designations. Neither step invents how long a game lasts: the second compares two distances the schedule itself sets. And the single game route now carries the time of the last designation read, which only the full route was reporting, so a reader of one game can tell a carried forward answer from a current one.
- The nightly load of the player ID crosswalk into the live database sent 400 bound values per statement, and that database refuses any query with more than 100, so the first real load would have failed and the injury worker would have kept every player unresolved. Batches now carry 25 rows, and a batch past that limit is refused before anything is sent.