diff --git a/.gitignore b/.gitignore
index 45369ce..762441f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -54,8 +54,10 @@ coverage/
# Screenshots
Screenshot*.png
-# Loading scripts
+# Loading scripts (one-off tournament data dumps, kept local only)
packages/bot/scripts/load*.ts
+# ...except the committed loader-harness template
+!packages/bot/scripts/load-example-spec.ts
# Database backups
*.backup.sql
diff --git a/CONTEXT.md b/CONTEXT.md
new file mode 100644
index 0000000..0758713
--- /dev/null
+++ b/CONTEXT.md
@@ -0,0 +1,51 @@
+# Scorebot
+
+Live score tracking for KC Ultimate (Tech) games, fed by WhatsApp chat messages. This glossary is the canonical language for game and stats concepts across bot, shared, and web packages.
+
+## Language
+
+### Game structure
+
+**Game**:
+One tracked match between our team and an opponent, made up of an ordered list of events.
+
+**Event**:
+A single recorded happening in a game: a goal, halftime, timeout, note, game start, or game end.
+
+**Point**:
+One unit of play from a pull to a goal. Every goal event ends exactly one point.
+_Avoid_: possession, rally
+
+**Point Ledger**:
+The derived, per-point account of a game: for each point, who scored, whether it was a hold or a break, which of our lines played it, and whether we forced a turn. All break/hold and line questions are answered from the ledger.
+
+**Starting on Offense**:
+Whether our team received the first pull. Determines possession at the start of each half; the receiving team flips at halftime.
+
+### Point outcomes
+
+**Hold**:
+A point won by the team that started it on offense.
+
+**Break**:
+A point won by the team that started it on defense.
+
+**Dirty Hold**:
+A hold where our O-line first lost possession and had to force a turn to win the point back. Counts as a hold, signals sloppy offense.
+
+**Failed Conversion**:
+A D-line point where we forced at least one turn but still conceded the goal.
+
+**Forced Turn**:
+A change of possession our defense caused (block or steal), as opposed to an opponent's unforced error. Only our own forced turns are logged.
+
+**Inferred Result**:
+A hold/break call made without knowing starting possession, guessed from consecutive scoring. Display-only: shown on the timeline, excluded from efficiency stats.
+
+### Lines
+
+**O-line**:
+The lineup fielded when we start a point on offense.
+
+**D-line**:
+The lineup fielded when we start a point on defense.
diff --git a/docs/adr/0001-do-first-dual-write.md b/docs/adr/0001-do-first-dual-write.md
new file mode 100644
index 0000000..95ae64e
--- /dev/null
+++ b/docs/adr/0001-do-first-dual-write.md
@@ -0,0 +1,12 @@
+# The Durable Object is authoritative; every write goes DO-first, then D1
+
+Every game mutation is a dual-write: the GameState Durable Object holds live state, D1 holds the durable mirror and query index. We decided the DO is authoritative for a live game and all writes flow DO → D1, with D1 failures surfaced as errors rather than swallowed. The GameStore module is the only place this ordering lives; callers never coordinate the two stores themselves.
+
+## Considered Options
+
+- **D1-first with the DO as cache** — one always-authoritative store, but forfeits the DO's single-threaded per-game ordering and invariant enforcement, and would require invalidation plumbing.
+- **Keep the historical split** — metadata updates used to write D1-first with a fire-and-forget DO update, while event writes went DO-first. Rejected: a failed fire-and-forget silently diverged the stores until eviction, and the ambiguity already produced a data-loss bug (`INSERT OR REPLACE` cascade-deleted a game's events; see the comment in `packages/bot/src/db/database.ts`).
+
+## Consequences
+
+On cold start (DO evicted) the store rehydrates the DO from D1, so D1 is briefly the seed — but never the write target of record. Any future write path added outside GameStore should be treated as a bug.
diff --git a/docs/adr/0002-do-rpc-transport.md b/docs/adr/0002-do-rpc-transport.md
new file mode 100644
index 0000000..c1e29c5
--- /dev/null
+++ b/docs/adr/0002-do-rpc-transport.md
@@ -0,0 +1,7 @@
+# GameState is called via Durable Object RPC, not fetch routing
+
+GameState originally exposed a hand-written `fetch()` switch over ~10 magic paths because pre-2024 Workers runtimes required an HTTP boundary for DOs. We decided to bump `compatibility_date` (from 2024-01-01 to a current date, ≥ 2024-04-03) and expose typed RPC methods instead, deleting the path switch and all request/response JSON marshalling in callers and tests.
+
+## Consequences
+
+The compatibility-date bump flips other runtime defaults accumulated since 2024-01-01; the deploy needs a smoke test of game creation, event add/undo, and the stats endpoints, with rollback being a redeploy of the previous compat date. Tests call DO methods directly instead of constructing 104 `new Request(...)` objects.
diff --git a/docs/architecture-review-2026-07-06.html b/docs/architecture-review-2026-07-06.html
new file mode 100644
index 0000000..67e8b47
--- /dev/null
+++ b/docs/architecture-review-2026-07-06.html
@@ -0,0 +1,398 @@
+
+
+
2026-07-06 · packages/shared · packages/bot · packages/web · no CONTEXT.md or ADRs found — domain terms below (Game, Point, break, hold, O-line) are drawn from the code itself
Problem. “Was this point a break or a hold?” is answered by three unrelated algorithms: calculateLineStats runs possession forward, isBreakScore scans backward from each goal, and gameUtils finds halftime a third way by timestamp. The same game page uses two of them and they can disagree. The opponent-by-symmetry arithmetic (themHolds = dLinePoints − dLineBreaks…) is inlined three times. No locality: a possession rule change lands in four files, two mental models.
+
+
+
+
+
Before — three algorithms, one concept
+
+
+flowchart TB
+ G[Game + events]
+ G --> A["calculateLineStats forward possession flag"]
+ G --> B["isBreakScore backward goal scan"]
+ G --> H["findHalftimePointIndex timestamp heuristic"]
+ A --> E[efficiencyStats]
+ B --> T[timeline / progression]
+ H --> T
+ E -. "symmetry math ×3" .-> S[stats.ts summary]
+ A --> SC[bot StatsCalculator]
+ classDef leak stroke:#dc2626,stroke-width:2px,color:#7f1d1d;
+ class A,B,H leak
+
Solution. Deepen calculateLineStats into a Point Ledger module in shared: one pass over events producing annotated points (scorer, break/hold, line, halftime crossing). Delete isBreakScore and the timestamp halftime heuristic; both web renderers and the bot's StatsCalculator read the ledger. Deletion test on isBreakScore: its complexity reappears — inside the ledger, once.
+
+
+
locality: possession rules in one module
+
leverage: one interface, 5+ call sites
+
timeline and stats can no longer disagree
+
existing utils.test.ts suite carries over
+
untested divergent algorithm gets deleted
+
pure function — the interface is the test surface
+
+
+
+
+
+
+
2 · A GameStore behind one seam: concentrate the DO ↔ D1 dual-write
Problem. Every write to a game is a dual-write — Durable Object memory plus D1 — but the pairing rules live in the Router, not behind a seam. mutateGameViaDO writes DO-first-then-D1; updateGame writes D1-first and fire-and-forgets the DO. The five-field patch logic is implemented twice (GameState and Router). Which store is authoritative depends on whether the DO is warm. This seam has already bitten: a comment in database.ts documents that INSERT OR REPLACE cascade-deleted a game's events. None of it is tested — no router.test.ts, no database.test.ts.
+
+
+
+
Before — Router orchestrates two stores, two orderings
+
+
+sequenceDiagram
+ participant R as Router
+ participant DO as GameState DO
+ participant D1 as DatabaseService
+ Note over R: addEvent / undo / delete
+ R->>DO: POST /events (magic path)
+ DO-->>R: {game}
+ R->>D1: saveStrategy? events : metadata
+ Note over R: updateGame — opposite order
+ R->>D1: saveGameMetadata FIRST
+ R--)DO: PUT /update (fire & forget)
+ Note over DO,D1: divergence on failure
+
Solution. Extract a GameStore module whose implementation owns both writes, the ordering, and rehydration. The Router shrinks to an HTTP adapter: parse request → call GameStore → serialize. GameStore accepts its DO-namespace and D1 dependencies rather than creating them — two adapters justify the seam: real bindings in prod, in-memory fakes in tests. The dual-write invariants (the exact logic behind the past CASCADE data-loss bug) finally get a test surface.
+
+
+
locality: sync rules in one implementation
+
dual-write ordering becomes an invariant, not a convention
Problem. Each tournament was onboarded by copying the previous script. 22 files each declare API_URL — and they disagree (9 workers.dev, 5 kcuda.org, 4 localhost, 4 mixed). 13 re-declare the Event shape instead of importing AddEventRequest from shared. 13 carry a hand-rolled edt() time helper with the game's date and a DST offset baked into the body. Deletion test: delete any one script and the logic doesn't vanish — it's already reappeared 13 times.
+
+
+
+
Before — 22 shallow modules, interface ≈ implementation
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ copied logic (API_URL, edt(), addEvent, Event type)
+ actual game data
+
+
+
+
After — one deep loader, scripts become data
+
+
+ yula-day1.ts
+ states.ts
+ amherst.ts
+ …
+ ← games + events only
+
+
+
+
interface · loadTournament(spec)
+
+
one API_URL, env-driven
+
timezone-correct time helper (date is data)
+
shared AddEventRequest type from @scorebot/shared
+
createGame / addEvent / endGame HTTP adapter
+
idempotent re-run (reload = same interface)
+
+
+
+
+
+
+
Solution. One scripts/lib/loader.ts module: loadTournament(spec) takes a declarative tournament spec (date, games, events); its implementation owns the HTTP adapter, the timezone math, and idempotent re-runs. Existing scripts shrink to data. The "fix" scripts (fix-missing-dplays et al.) become ordinary re-loads through the same interface.
+
+
+
leverage: one implementation, 22 call sites
+
next tournament = a data file, zero logic
+
one API_URL instead of 22 disagreeing ones
+
DST bugs concentrate in one time helper
+
~2,000 lines of copied logic deleted
+
+
+
+
+
+
+
4 · Finish the gameClient seam: one path from pages to the API
Problem. The seam exists — gameClient.ts — but only main.ts crosses it. stats.ts and games.ts route around it with hand-rolled fetches; API_BASE_URL is declared three times; “list games” has two different URLs (/games vs /games?limit=100); each page owns its own setInterval poller and try/catch/showError block. The client currently fails the deletion test — one caller — because the other pages leak past it.
+
+
+
+
Before — seam bypassed by 2 of 3 pages
+
+
+flowchart LR
+ M[main.ts] --> C[gameClient]
+ C --> API[(Worker API)]
+ S[stats.ts] -- "own fetch ×2 own API_BASE_URL" --> API
+ GA[games.ts] -- "own fetch + poller own API_BASE_URL" --> API
+ linkStyle 2,3 stroke:#dc2626,stroke-width:2px;
+
+
+
+
+
After — every page crosses the same seam
+
+
+flowchart LR
+ M[main.ts] --> C
+ S[stats.ts] --> C
+ GA[games.ts] --> C
+ subgraph C[gameClient — deep]
+ direction TB
+ I["fetchGames · fetchGame · fetchStats poll(fn, interval) · one base URL · one error path"]
+ end
+ C --> API[(Worker API)]
+ style C fill:#0f172a,color:#fff,stroke:#0f172a
+ style I fill:#1e293b,color:#cbd5e1,stroke:#334155
+
+
+
+
+
+
Solution. Deepen gameClient into the only module that knows the base URL, endpoints, error handling, and polling cadence; pages call typed functions. With a fake adapter at this seam, page logic becomes testable without a network — two adapters make it a real seam.
+
+
+
locality: endpoint/auth changes in one module
+
“list games” means one thing again
+
poller written once, three call sites
+
fake adapter unlocks page tests
+
+
+
+
+
+
+
5 · Give renderers an interface: data in, element out
Problem. Every renderer is a void function that reaches into document.getElementById('…') for elements that must pre-exist in the HTML scaffold. The interface between TS and HTML is a bag of ~30 untyped string IDs checked by nothing, and stats.ts types its inputs as any despite PlayerStats/GameContextStats existing in shared. Result: zero web tests, because nothing can be exercised without booting the exact page DOM.
+
+
+
+
Before — interface as wide as the implementation
+
+
+
+
interface: 30+ element IDs, ambient DOM, any inputs, call-order rules
+
implementation: formatting + table building
+
shallow
+
+
+
+
+
After — short interface, tall implementation
+
+
+
render(stats: PlayerStats[]) → HTMLElement
+
implementation: all formatting, tables, badges; page shell mounts the result
+
deep
+
+
+
+
+
+
Solution. Renderers take typed data and return an element; only the page shell touches document. Restore the shared types at the interface (delete the anys). Speculative because it touches every component for a payoff that mostly lands if web tests are actually wanted — a cheap first step is just typing the seam: replace any with the shared types, no structural change.
+
+
+
the interface becomes the test surface
+
compiler checks the shared contract again
+
ID-string coupling to HTML shrinks to the shell
+
+
+
+
+
+
Smaller findings (no card warranted)
+
+
Three regexes recognize “a defensive play in a note” — MessageParser.ts:197, StatsCalculator.ts:113, shared/utils.ts:177. Folds naturally into the Point Ledger work (candidate 1) or a small shared recognizer.
+
Dead export:getGameDuration (shared/utils.ts:74) has no caller outside its own tests — a tested pass-through; delete it. getGameByChatId (database.ts:164) is likewise unused.
+
shared/utils is two things fused: a deep core (types + calculateLineStats) plus single-caller one-liners (formatScore, formatTime) that add interface without leverage.
+
“Point index” is reconstructed three ways — possession toggle, goal-event index, and lineups[].pointNumber — assumed aligned with no shared helper guaranteeing it. Also folds into candidate 1.
+
innerHTML vs createElement coexist with no rule (games.ts:172, timeline.ts:103 vs the rest) — pick one in candidate 5.
It deepens the domain heart of the whole system — break/hold is what the product is — and both adapters already exist (bot StatsCalculator and web renderers), so the seam is real, not hypothetical. It's a pure-function refactor with a test suite already in place to extend, it deletes an untested divergent algorithm that can visibly disagree with the stats on the same page, and it hands candidates 2 and 5 a cleaner shape to build on. The GameStore (candidate 2) is the follow-up: it guards the one seam that has already caused data loss.