What changed, and why
A run in progress lived entirely in the Pinia store and vanished on any refresh or crash. That hurt: every turn is a model call, so a stray reload threw away minutes of play and real spend. The only persistence was end-state — saveRunSnapshot() fired once, at victory or defeat.
This adds the missing mid-run save. The server stays stateless per turn (the client is still the system of record), so the fix is storage, not a turn-engine rewrite: after each resolved-but-still-playing turn the client autosaves a draft of the live state to a new server table; on the next page load it reads that draft back and drops the player onto the in-progress board. The draft is keyed to the same subject runs and balances already use — a real Supabase user (anonymous by default), or the device cookie — so an anonymous player's run carries over to their account for free when they sign in with a magic link.
The blast radius is contained. run_snapshots (the finished-run record behind "your runs", share, and replay) is untouched; the draft is a separate table, a separate shape, and a separate set of endpoints. The terminal save path keeps working exactly as before — the draft just rides alongside it.
The draft contract
The draft's shape lives beside RunSnapshot in the client-safe contract module (types only, no runtime imports). It serializes the durable live state and nothing transient: the running totals, the full thread (both sides — the snapshot keeps only the player's dispatches, but the AI replies cost a model call to regenerate), the un-trimmed ledger with whenSigned and causalChain (the footholds the next turn reads back), the contacted figures, the active contact, the Chronicle, and any replay lineage.
It deliberately leaves out the loading flags, the runEpoch/sequence counters (those must survive a reset, not a reload), and figureGrounding — the active figure is re-grounded from the live record on resume, so a draft never carries a stale dossier. The in-memory Date stamps become ISO strings here, since jsonb is dateless.
The design note, the independent version stamp, and the RunDraft shape.
utils/run-snapshot.ts · 312 lines
⋯ 205 lines hidden (lines 1–205)
⋯ 61 lines hidden (lines 235–295)
Clamp the untrusted draft at the boundary
A draft is POSTed by the client and, on resume, rehydrates the live store — so it is trusted no more than a send-message body. The server-only validator coerces and bounds every field. The lines that matter most are the counter clamps: between turns there is no server-side turn engine, so remainingMessages is purely client-authoritative. A forged draft is the only lever a crafted request has over it.
The posture (top), and the clamps that make a forged draft harmless.
server/utils/run-draft.ts · 273 lines
⋯ 168 lines hidden (lines 19–186)
⋯ 69 lines hidden (lines 205–273)
The draft store
Storage follows the repo's env-degradation idiom exactly: one port, two adapters chosen by config. In-memory by default, so npm run dev and the unit tests need nothing; Supabase when the URL + secret key are present. The interface is just three verbs — upsert the draft, read the subject's latest, delete one — and reads are scoped to the owner, so a player only ever resumes their own run.
The port, and the durable read: the subject's latest draft, newest first.
server/utils/run-draft-store.ts · 125 lines
⋯ 26 lines hidden (lines 1–26)
⋯ 47 lines hidden (lines 38–84)
⋯ 26 lines hidden (lines 100–125)
Autosave, clear, and resume — woven into the store
Three new actions sit beside saveRunSnapshot. **saveRunDraft** mirrors it for a still-playing run, serializing Date stamps to ISO strings. **clearRunDraft** drops the draft (it takes an explicit run id so resetGame can clear the run it is tearing down). **resumeDraft** reads the latest draft and rehydrates the store.
The wiring lives in the sendMessage tail. The terminal branch (victory/defeat) gains a clearRunDraft() next to the snapshot save; a new still-playing branch autosaves — an immediate write (in case the chronicle refresh hangs) plus a re-save once the fresh Chronicle lands, the same two-beat shape the terminal save already uses.
resumeDraft is the subtle one. It reuses the draft's runId (so no second credit is charged), revives the ISO stamps to real Dates, then re-grounds the active figure and restores the saved contact year over the mid-lifetime default that grounding would otherwise pick. Read the two guards on it closely — they are covered in the callout.
The turn-tail wiring, saveRunDraft's serialize, all of resumeDraft, and resetGame's clear.
stores/game.ts · 1651 lines
⋯ 1125 lines hidden (lines 1–1125)
⋯ 296 lines hidden (lines 1140–1435)
⋯ 77 lines hidden (lines 1463–1539)
⋯ 44 lines hidden (lines 1602–1645)
Resume on load, and the finished-run safety net
The page kicks off resume from onMounted, after the balance load and any magic-link return settle, and only when the board is empty. It is auto-resume: the player lands back on the in-progress board, and the header's existing ↻ "new timeline" control is the abandon path (it clears the draft through resetGame).
One guarded call at the end of the mount sequence.
pages/play.vue · 625 lines
⋯ 461 lines hidden (lines 1–461)
⋯ 156 lines hidden (lines 470–625)
The read endpoint adds a belt to the autosave's suspenders. Clearing a draft on terminal save is best-effort and fire-and-forget; should it ever miss, a finished run's draft could linger. So the GET refuses to hand back a draft whose run already saved a terminal snapshot — the snapshot store is the source of truth for "done".
Read the subject's latest draft, then refuse it if the run already finished.
server/api/run-draft.get.ts · 24 lines
The table and the write/clear endpoints
The migration adds run_drafts: one row per run, run_id PK referencing runs(id), upserted per turn, RLS on with no policy (service-key only) like every other table. An index on (user_id, updated_at desc) serves the "latest draft" read.
supabase/migrations/0008_run_drafts.sql · 22 lines
The write and clear routes are thin and mirror the existing run-save / run-share conventions: POST validates with sanitizeDraft, gates the run id to a real uuid (a degraded local id has no runs row to anchor the FK), and upserts under currentSubject; DELETE is uuid-gated, owner-scoped, and idempotent.
server/api/run-draft.post.ts · 38 lines
server/api/run-draft.delete.ts · 21 lines
Verification
The full suite is green: 979 unit tests pass and nuxt typecheck is clean. Four new specs carry the feature — the validator's clamps and coercion, both store adapters plus their Supabase query shapes, the three store actions (autosave / clear / resume, including the replay-seed guard), and the endpoint contract.
Beyond the suite, an adversarial multi-agent review read the branch across security, resume correctness, lifecycle, and contract-consistency lenses, then verified each finding independently. It surfaced one real cross-feature bug — the replay-seed clobber in the section above — which is now fixed and pinned by a test.