Plan 2 — the grid surface and live sync
Thirteen tasks, one session, b54989d. The branch now serves a spreadsheet in a browser that live-updates across tabs, deployed and verified on the canary.
Plan: docs/superpowers/plans/2026-08-31-sheets-grid-live.md.
What landed
The DEK (nothing built a seal.Sealer before, so no cell could be stored); members, mapping an address to the uint64 every authored column wants; sheet and tab creation with sealed titles; store.SetReport, which is Set plus an account of what changed; internal/sheets/live, an in-process fan-out hub; a view layer over rastrillo's tokens; the grid JSON API; SSE; the vendored grid; the scriptless table view; and spec §11.1's contrast trigger.
Three bugs worth naming, because a green suite found none of them
1. Jspreadsheet v5 takes its event handlers on the root config, not on a worksheet. Nested one level too deep they are accepted in silence and never called. The grid rendered, the console was clean, every Go test passed, and not one edit was ever sent to the server. Two real browser tabs found it in about a minute. tools/drive/drive.mjs is committed as a manual gate for exactly this class — no static check knows which options a third-party library reads.
2. The JS line-cap guard walked past everything its exemption list named. It reused repo_test.go's skipDir, which skips any directory called vendor — correct for a Go dependency tree, exactly wrong for our vendored JavaScript. It passed while proving nothing, and the exemptions read as load-bearing while being dead. TestEveryJSExemptionIsLive now asserts the walk reaches each one. This is the "suite that agrees with itself" failure the spec's §12 warns about, arriving in the guard rather than in the code.
3. rst-sr-only written as an attribute. One of AGENTS.md's seven class-only utilities, so it matched nothing and a screen-reader label rendered in the middle of the toolbar. Caught by a screenshot, now caught by TestUtilitiesAreNeverWrittenAsAttributes, which I verified fails on the exact markup that caused it.
A structural change that needs a second opinion
The models moved from package sheets to package store. store imported sheets for those structs, so sheets could not import store back — and every route that reads a cell is in package sheets. Putting them where the persistence is removes the cycle rather than routing around it, and it is where models.go's own comment on Event already said they belonged. Member stayed behind: who may hold a session is an app concern.
Three store tests moved to the external test package as a consequence (they migrate with sheets.BootSchema); export_test.go carries the seams they need, deliberately as seams, because what those tests prove is that the real crash-safety path works.
rastrillo migration check still agrees, which was the risk.
Two deferred minors from plan 1, both handled
Sheet.DeletedAt is *time.Time, so soft-delete scoping is not automatic — listSheets excludes deleted rows explicitly, as this thread said the first task listing sheets would have to.calc.CellKey's position+1 convention is valid until rows move. Plan 2 adds no insert, delete or reorder, so it is untouched and still owed by whoever adds one.
Deliberately not built, with triggers
Stated in the plan rather than discovered later. Sharing is not in this (the instance allowlist is the team, and §13's ordering of sharing before live sync is inverted — reason written down). Presence: trigger is the first time two people report colliding on a cell. Undo: trigger is the grid being in daily use. The event log already records what undo needs.
Spec §7's idle policy is implemented differently from its wording, and the code says so: there is no presence heartbeat, because a heartbeat POST cannot reach the goroutine holding the stream. The client closes its stream when the tab is hidden instead, which is stronger — a hidden tab holds no socket rather than an idle one. The server cannot enforce that (EventSource retries after any close), so its timeouts are a backstop, and the comment says exactly that.
Verification
make test green — all six targets, including -race over store and live. rastrillo migration check agrees. rastrillo doctor reports tokens.css, theme.css and rastrillo.js all matching v0.22.0.
Browser-driven on a real instance: two tabs on one sheet, type 6 and =A1*7 in the first, and the second shows 6 and 42 without a reload. Its formula bar shows =A1*7 for the cell displaying 42. No console errors.
Canary sheets.bds.oncarlos.com runs b54989d, confirmed by polling GET /api/version — the process, not the edge. It took ~2 minutes to cycle, consistent with the >90s this repo has recorded before.
SHEETS_DEK was added to the app's secrets. The new binary refuses to boot without it. No sheet data existed before tonight, so nothing could be orphaned by minting a fresh key.
Review status — NOT satisfied
Per §Review, a review by the model that wrote the code is not a review. I am the author. I have not dispatched a reviewer, and I am not going to pass a self-read off as a review pass.
So this branch is not mergeable yet. What it needs is a review pass by a model at least as capable as the author, recorded here. The structural change (models moving packages) and handle_set.go's refactor into SetReport are where I would point a reviewer first: the second one touches the crash-safety path that most of plan 1's review effort went into.
The last mile I could not walk: signing in on the canary needs a magic link emailed to paul@keymail.dev, which I cannot read.