fix: final review — parse gate on updates, tolerant view, snapshot cascade, failed save state, verified assets
A poison update no longer bricks a deck. appendUpdate applies the bytes on the fresh handle outside the transaction, reads the deck back and parses it before anything is stored; a parse failure is a 422 and the log is untouched. The view (core/src/view.rs, mirrored in fixtures/view.mjs) rebuilds every fixed-shape leaf — box, background, fonts, phone, assets — from the fields that have the right type and omits what it cannot make, so the four shapes the review reproduced are accepted and render, and a deck that already holds such data opens. fixtures/poison.mjs writes the malformed updates the Go test posts. deleteDeck also deletes deck_snapshots and the new deck_assets rows. The editor's status line has a `failed` state of its own for a dropped batch, with its catalog key; the mapping is fixtures/status.mjs. serveAsset requires a deck_assets row (written by registerAsset after the bytes were read, sniffed and sanitised) as well as the deck's map, so a register_asset op naming an unverified presigned PUT is never served. Migration 0007 creates the table. The strip moves only the items out of place on a remote reorder and restores focus. The gate no longer deletes untracked artifacts on a freshness failure. docs/api.md lists the assets endpoint's real refusal words; AGENTS.md notes canvas.mjs as the one module over 300 lines; README says CI does not commit the bundle and that uploads need a secure context; spec §3.2 records the phone-badge deviation.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01563XGCgxcSZToEXwuTBsKE
.amadan/README.md+7 −4.amadan/ci.d/10-gate+12 −6AGENTS.md+16 −4README.md+6 −1core/src/view.rs+251 −6docs/api.md+10 −2docs/superpowers/specs/2026-09-07-slides-design.md+5 −0fixtures/dom-stub.mjs+9 −0fixtures/editor-entry.js+12 −5fixtures/poison.mjs+104 −0fixtures/status.mjs+30 −0fixtures/status.test.mjs+25 −0fixtures/strip.mjs+13 −5fixtures/strip.test.mjs+41 −1fixtures/view.mjs+89 −5fixtures/view.test.mjs+58 −0internal/slides/assets.go+44 −8internal/slides/core/testdata/slides_core.wasmbinaryinternal/slides/decks.go+10 −0internal/slides/editor.go+2 −0internal/slides/live.go+57 −35internal/slides/locales/en.toml+1 −0internal/slides/migrations/0007_create_deck_assets.sql+4 −0internal/slides/migrations/schema.sql+4 −0internal/slides/models.go+29 −0internal/slides/static/{editor.d193d83bc4c2adaa.js => editor.8282c2af4a08f493.js}+15 −15internal/slides/templates/editor.html+5 −3internal/slidestest/assets_test.go+61 −0internal/slidestest/decks_test.go+33 −0internal/slidestest/editor_test.go+1 −1internal/slidestest/live_test.go+98 −0internal/slidestest/testdata/poison/assets.yjsbinaryinternal/slidestest/testdata/poison/background.yjsbinaryinternal/slidestest/testdata/poison/box.yjsbinaryinternal/slidestest/testdata/poison/fonts.yjsbinaryinternal/slidestest/testdata/poison/magnitude.yjsbinary
CI passed — run details
Intent
<task-notification> <task-id>a791d599856ae38ae</task-id> <tool-use-id>toolu_01BYcXDQ4Y5TryEvbHangcfz</tool-use-id> <output-file>/home/paulca/.cache/tmp/claude-1001/-home-paulca-amadan-net-oficina/2420c44b-a27a-4e0d-bbe7-8b3ca0464c21/tasks/a791d599856ae38ae.output</output-file> <status>completed</status> <summary>Agent "S2 Task 7: gate and docs" finished</summary> <note>A task-notification fires each time this agent stops with no live background children of its own. The user can send it another message and resume it, so the same task-id may notify more than once.</note> <result>Status: DONE. Task 7's deploy bullet (`make release`, `carlos deploy`, canary walk, push, branch discussion) is untouched — that is the controller's. Commit: `526061d` — docs: slice 2 in AGENTS, README, PRODUCT, api.md; gate runs the JS toolchain (7 files, +671/−108, both trailers present, nothing pushed). Gate: green end to end on a clean `git archive HEAD` export (fresh `git init` so status/diff are meaningful) — `128 pass, 0 fail` node tests, 5 vectors regenerated clean, bundle rebuilt to the same `editor.d193d83bc4c2adaa.js`, `make ci` green, `gate: ok`, exit 0, export tree clean afterwards. Both new checks proven able to fail: a tampered vector (exit 1) and a renamed committed bundle (`D` + `??`, the stale-bundle message, exit 1, tree restored). Concerns: the vector-freshness step is largely redundant with `ops.test.mjs`, which fires first (the untracked half still earns it); `npm ci` now needs the npm registry at run time and whether the amadan runner reaches it is unverified; the gate has never run on the runner with these steps; `require_committed` uses a path-scoped `git clean -fdq` on its failure branch; and every claim in the docs comes from reading code and the six task reports, not from opening the editor. Report: `/home/paulca/amadan.net/oficina/slides/.claude/worktrees/slice-1/.superpowers/sdd/2026-09-07-slice-2-editor/task-7-report.md`</result> <usage><subagent_tokens>238266</subagent_tokens><tool_uses>147</tool_uses><duration_ms>913681</duration_ms></usage> </task-notification>
<task-notification> <task-id>a2bf2ef366d00a320</task-id> <tool-use-id>toolu_01JdrQzCmKKyrqkaqE1r3C3X</tool-use-id> <output-file>/home/paulca/.cache/tmp/claude-1001/-home-paulca-amadan-net-oficina/2420c44b-a27a-4e0d-bbe7-8b3ca0464c21/tasks/a2bf2ef366d00a320.output</output-file> <status>completed</status> <summary>Agent "Review slice 2 Task 7" finished</summary> <note>A task-notification fires each time this agent stops with no live background children of its own. The user can send it another message and resume it, so the same task-id may notify more than once.</note> <result>### Spec Compliance Task 7's first two bullets are implemented and match the brief. - **Gate** (`.amadan/ci.d/10-gate`): adds `node`/`npm` to the tool-check loop, `npm ci --cache "${NPM_CACHE:-$HOME/.npm}"` from `fixtures/`, `node --test *.test.mjs` (deliberately not `node --test fixtures/`, which Node 24 silently no-ops on), `node generate.mjs` + a freshness check on `fixtures/vectors`, `node build-bundles.mjs` + a freshness check on `internal/slides/static`, then `make ci` (which still runs the Go `vectors_test`). Order, `set -euo pipefail`, and the wasm `cmp` step are all unchanged and intact — verified by reading the full current script (`.amadan/ci.d/10-gate` lines 1–166). - **Docs**: `AGENTS.md`, `README.md`, `PRODUCT.md`, `docs/api.md` all rewritten to describe the editor, the live-sync wire, the fixtures rule, the bundle, the `href` ruling, the seams (`render.SlideWith`/`serveAsset`), what's not built, and the 503-without-a-store behavior. `.amadan/README.md` updated with the JS toolchain section and the `npm ci`-needs-registry trade-off. - Task 7's third bullet (`make release`, `carlos deploy`, canary walk, push, branch discussion) is explicitly and correctly left to the controller — the report says so plainly, and nothing in the diff claims it happened. ### Strengths - `require_committed()` is a clean, well-justified abstraction: it correctly explains *why* both `git diff --exit-code --name-status` and `git status --porcelain` are needed (a moved content hash produces a deletion **and** an untracked file — `git diff` alone misses half of it), and the report backs this with a concrete RED-run reproduction (renaming the committed bundle file) that shows exactly the failure mode the second check catches. - The report is unusually self-aware: it flags its own weak points (vector-check redundancy with `ops.test.mjs`, `npm ci` needing the registry, the gate never having run on the real runner, and the `git clean -fdq`) rather than hiding them. - Spot-checking a dozen factual claims against code found the documentation largely accurate: routes in `app.go`, `Slides-Seq`/`Slides-Snapshot` headers and 4-byte framing in `live.go`, 1 MiB update cap, SSE timings (15s ping / 5min TTL from `presentation.go`), presence heartbeat (20s, `fixtures/presence.mjs`) and two-missed-beats sweep (`live/hub.go`), `assets.go`'s 20 MiB cap and four allowed types, the editor's three panes and three-section inspector in `editor.html`, the `href` one-key-map shape in `core/src/schema.rs`, the 318-key catalog count (`grep -cE '^[A-Za-z0-9_.-]+ *=' internal/slides/locales/en.toml` reproduces exactly), and the "five vectors" claim (5 files in `fixtures/data/` and `fixtures/vectors/`) all check out. - No doc claims the editor was walked by hand in this task; the only "walks" references are to the automated `browser_test.go`, consistent with the report's honest disclosure that this task's docs came from reading code and prior reports, not from using the editor. ### Issues **Important** 1. **`require_committed`'s `git clean -fdq` is not clearly a data-loss warning, though it is narrowly scoped and largely justified.** It only runs on the failure branch, and only against the two directories being checked (`fixtures/vectors`, `internal/slides/static`), both of which are pure generated-and-committed output by design — nothing legitimate is meant to be hand-authored there. So in practice what gets deleted is either regenerable build output (the intended case) or a forbidden hand-edit to a vector (arguably correct to discard). The real gotcha: if a developer adds a new `fixtures/data/*.json` and runs the gate for the first time, `node generate.mjs` correctly produces the new vector files, the check sees them as untracked ("stale"), prints `gate: vectors are stale: run node fixtures/generate.mjs and commit` to stderr, and then **immediately deletes the very file the message told them to commit** before they can `git add` it. Re-running `generate.mjs` recovers it, but the sequence is confusing and worth a one-line fix (either mention explicitly that the check restores the directory, or reorder the message to say so). Not a blocker given the narrow scope, but it's the one named risk here that isn't fully resolved, only noted by the report's own concern #4. **Minor** 2. **`docs/api.md`'s refusal list for `POST /d/{sqid}/assets` is slightly wrong.** It lists `unsupported_type` as one of that endpoint's possible refusals (docs/api.md, "Refusals are..." under `### POST /d/{sqid}/assets`), but `registerAsset` (`internal/slides/assets.go` lines 268–360) never emits `unsupported_type` — that code belongs only to `signUpload` (line 165). A bad content type reaching `registerAsset` comes back as `not_an_image` (from `sniffImage`, line 328) instead. One word in an enumerated list; trivial fix. 3. **AGENTS.md's line-count claim is off by 19 lines.** "How the editor is put together" states "every other module under `fixtures/` is one concern and stays under 300 lines," but `fixtures/canvas.mjs` is 319 lines (`wc -l fixtures/canvas.mjs`). Either the claim needs qualifying (e.g., "close to 300") or the file needs trimming; as written it's factually false. ### Assessment **Needs fixes** — but the fixes are small and mechanical, not structural. The gate implementation itself is sound: step order, `set -euo pipefail`, the wasm `cmp`, and `NPM_CACHE` handling are all intact and correct, and the `require_committed` design is well-reasoned and proven against real failure scenarios. The documentation is substantially accurate on spot-check (10 of 12 checked claims verified exactly against code), but two concrete factual errors were found (the `unsupported_type` refusal misattribution and the false "under 300 lines" claim), and the one named safety risk the controller asked about — the failure-branch `git clean -fdq` — is acceptable in principle but not clearly communicated as a discard-on-failure behavior to a developer reading the printed message in the moment. None of these require redesign; they're a few lines each to correct before merge.</result> <usage><subagent_tokens>132208</subagent_tokens><tool_uses>36</tool_uses><duration_ms>266151</duration_ms></usage> </task-notification>
<task-notification> <task-id>a25e9c1897f6441be</task-id> <tool-use-id>toolu_01WL8GWWZfFVMKqhQZwPqJw8</tool-use-id> <output-file>/home/paulca/.cache/tmp/claude-1001/-home-paulca-amadan-net-oficina/2420c44b-a27a-4e0d-bbe7-8b3ca0464c21/tasks/a25e9c1897f6441be.output</output-file> <status>completed</status> <summary>Agent "Slice 2 final whole-branch review" finished</summary> <note>A task-notification fires each time this agent stops with no live background children of its own. The user can send it another message and resume it, so the same task-id may notify more than once.</note> <result>I worked in four passes (server transport and integrity; core tolerance and CSRF; a poison-update experiment in a scratch copy plus a11y/docs/tests; canary probes), no subagents. Worktree untouched; the experiment ran in `/home/paulca/.cache/tmp/claude-1001/slides-scratch` and `/poison`. ### Summary Slice 2 lands the whole plan: the live-sync wire, hub, snapshots, the editor, inspector, canvas, switcher, presence, assets and the JS-aware gate, with real HTTP tests and a canary that answers the right unauthenticated shapes (`/api/version` → `526061d`; signed-out GETs 303 to `/signin`; POST/PUT without a session 403). One Critical stands: a member can POST a well-formed Yjs update whose values have the wrong *type* (a `box` with a string `x`, a `background.kind` number, a non-object `assets` entry, a non-string `fonts` field), the core accepts it, `commitUpdate` stores it, and from then on `openDeck` fails at `deck.Parse` for every surface and every write path — the deck is bricked with no path back short of DB surgery. Two Importants (snapshots are not cascaded on delete; a dropped 4xx batch is shown as "Offline, retrying") and a handful of small items round it out. ### Plan alignment All seven tasks are present and match the plan's Global Constraints: framing and `Slides-Seq` derived from the body (`live.go:113`), `Slides-Snapshot` at `since=0` only, SSE with `update`/`presence`/ping and the presentation stream's timings, snapshots at 200 by the crossing request after commit, presence in the hub only, 503 without a store, one bundle, Docs' three modules copied verbatim. Op vocabulary: `core/src/ops.rs` has 15 variants; `fixtures/ops-parse.mjs` has the same 15 cases; nothing on one side only. The browser builds no slide structure itself (`innerHTML` only receives server HTML in `stage.mjs:234` and `strip.mjs:125`; the rest is chrome — handles, avatars, list rows). Deviations, all rulinged or reported: `verify-rust.mjs` became `core/vectors_test.go` (equivalent, and the gate regenerates vectors); href map shape (row 12); fonts as CSS strings; the phone-override badge is in the inspector, not on the stage block (spec §3.2, Task 5 report); the "failed" status state the ledger deferred to Task 7 was not added. ### Findings **Critical** 1. **A poison update bricks a deck for everyone.** `commitUpdate` (`live.go:314-317`) only asks the core whether the bytes *apply*; it never asks whether the resulting view still parses. The Rust view is tolerant at the top level (`schema::maps_of`, `get_string` return defaults for wrong shapes) but passes `box`, `background`, `fonts`, `phone` and every `assets` entry through as whatever object is there (`view.rs:105,126,147,155`), and `deck.Parse` (`deck.go:173`) unmarshals into typed structs. Reproduced in the scratch copy with updates minted by the fixtures' own `yjs`: of 15 shapes, four brick — `box.x = "a"`, `background.kind = 5`, `fonts.body = []`, `assets.x = "str"` — each with `deck: parse view: json: cannot unmarshal …`. `openDeck` (`decks.go:192-221`) sits under the viewer, the editor, `deckSlide`, `serveAsset`, the API's `editDeck` **and** `appendUpdate` itself, so after the row commits nothing can render and nothing can write. Fix, two layers: (a) in `appendUpdate`, move `h.Apply` out of the transaction and follow it with `h.JSON` + `deck.Parse` *before* the insert, answering 422 (`errUpdateRefused`) on a parse failure — this costs no extra wasm call, because `mirrorMeta` (`live.go:389`) already does that read after commit; hand it the parsed view instead. (b) In `view.rs`, emit `box`/`background`/`fonts`/`phone` only when every field has the right type and skip `assets` entries that are not maps with a string `ref`, mirror that in `fixtures/view.mjs`, add a vector. Add `TestPoisonUpdateDoesNotBrickTheDeck` in `slidestest/live_test.go` posting the four shapes and asserting `GET /d/{sqid}` still 200s. The existing `TestRefusedUpdateIs422` only posts non-Yjs bytes. **Important** 2. **`deleteDeck` does not delete `deck_snapshots`** (`decks.go:1031-1047` deletes presentations, updates, grants, share links, the row). `decks.id` is `AUTOINCREMENT` (`0002_create_decks.sql`), so the orphan cannot be replayed into a reused id, but each orphan is a whole copy of a deleted deck kept forever. One more `tx.Where("deck_id = ?", d.ID).Delete(&DeckSnapshot{})` and an assertion in `decks_test.go`. 3. **A dropped batch is reported as "Offline, retrying."** `save-queue.mjs` correctly drops a 4xx batch and emits `failed`; `editor-entry.js:390-395` maps every non-ok state to `offline` and marks `sessionFailed`, so the person sees `slides.editor.status.offline` ("Offline, retrying") while their edits have been discarded and the resync has rebuilt the doc without them. Add a `failed` state with its own catalog line ("Some changes could not be saved; reload to continue") — the ledger deferred this to Task 7 and Task 7 did not do it. 4. **The SVG gate can be bypassed by writing the ref straight into the document.** `registerAsset` sanitises and stores clean bytes under a new hash, but the doc is written by the client's `register_asset` op, and any member can POST an update naming the *unsanitised* hash they PUT via the presigned URL. `serveAsset` (`assets.go:475-487`) then finds the ref in `view.Assets` and, with an S3 store, 302s to the raw object — served from the bucket's origin (so not an XSS on this app; the Dir path is protected by the CSP sandbox). Not a merge blocker while no store is bound; before one is, either record verified refs server-side (a small `deck_assets(deck_id, ref)` table written by `registerAsset` and required by `serveAsset`) or sniff-and-sanitise on the Dir path and only presign refs that table holds. **Minor** 5. `strip.mjs:105` re-appends every `<li>` on a remote reorder, which drops focus from a focused strip item (WCAG 2.2, ledger's own deferred line). Only move nodes whose position differs, and restore `document.activeElement` afterwards. 6. Presence `client` is client-chosen (`live.go:616`): a member can overwrite a colleague's cursor by reusing their client id. Cosmetic within a team; keying the map by `(user, client)` closes it. 7. `/s/{token}/assets/{ref}` and `serveAsset` generally instantiate a wasm module and replay the log per image, and the share route is unauthenticated. The per-(deck, seq) view cache the ledger deferred would cover both; until then a share-link holder can make each image cost a replay. 8. A member in a loop can open unbounded streams; each is one goroutine for up to 5 minutes. Same as Docs; a per-user cap on `Subscribe` is a later change. 9. Undo scoping is right (`trackedOrigins = {ySyncPluginKey, LOCAL}`, remote bytes tagged `REMOTE`), the CSS `[slides-editor] [hidden]` rule is right, the presence live region speaks only on join/leave — credit, no action. ### Deferred/parked triage Fix before merge: #3 (failed state), #5 (strip focus), and the ledger's "delete cascades" gap surfaces as #2. Later: 409/ping/TTL tests, `ids.mjs` test, `raw=1`, thumb scale, `data-seq`, nudge coalescing, the stage phone-override badge (record the spec §3.2 deviation in the spec), the `(deck, seq)` cache (#7), the real-bucket PUT test and the https requirement for `crypto.subtle` (say it in README), the snapshot ABI column (snapshot bytes are Yjs v1, not ABI-bound; fine to wait), the `nohash` message. Accepted: `mirrorMeta` lag window. ### Docs consistency Ten claims checked against code and all hold: 503 without a store; snapshot cadence 200; the wire's framing, headers and `since=0` snapshot; presence whole-set with name/colour from the users row; both write paths publish and snapshot; `build-bundles.mjs` deletes stale hashes; 20 MiB and four types; three capability-keyed asset routes; undo scoped to the person; the gate's step list. One wording nit: README's "CI does not build it for you" sits beside "the gate rebuilds and diffs" — say "CI does not commit it for you". ### Tests Real HTTP throughout `slidestest`; the stream test is event-driven with no sleeps; the only `time.After` is a poll in the browser drive. Seven browser drives plus the upload drive match what the reports claim. `make ci` is the Go half of the one gate, and the gate's `require_committed` restores the tree on failure — well done. ### Verdict: Needs fixes Blocking: **#1** (pre-commit parse gate in `appendUpdate` plus the tolerant view and its test), **#2** (snapshot cascade), **#3** (failed status state). Recommended in the same round because they are small: **#5**. **#4** must land before `carlos store create` is run.</result> <usage><subagent_tokens>176682</subagent_tokens><tool_uses>56</tool_uses><duration_ms>469170</duration_ms></usage> </task-notification>
Attributed to this commit: 19.7 g CO₂e (2519523 tokens). This is an attribution, not a measurement — usage counted from this commit's own marker forward to the next one on slice-2-editor, never backward from it. A turn that produced more than one commit books all of its tokens to the last one, and a concurrent session on this branch can add to the same figure. Some of it was priced using the shared default rate, because no model-specific factor row exists yet for at least one model involved. The Carbon view has the repo total, which is never derived from this attribution.
.amadan/README.mdLoading diff…
.amadan/ci.d/10-gateLoading diff…
AGENTS.mdLoading diff…
README.mdLoading diff…
core/src/view.rsLoading diff…
docs/api.mdLoading diff…
docs/superpowers/specs/2026-09-07-slides-design.mdLoading diff…
fixtures/dom-stub.mjsLoading diff…
fixtures/editor-entry.jsLoading diff…
fixtures/poison.mjsLoading diff…
fixtures/status.mjsLoading diff…
fixtures/status.test.mjsLoading diff…
fixtures/strip.mjsLoading diff…
fixtures/strip.test.mjsLoading diff…
fixtures/view.mjsLoading diff…
fixtures/view.test.mjsLoading diff…
internal/slides/assets.goLoading diff…
internal/slides/core/testdata/slides_core.wasmLoading diff…
internal/slides/decks.goLoading diff…
internal/slides/editor.goLoading diff…
internal/slides/live.goLoading diff…
internal/slides/locales/en.tomlLoading diff…
internal/slides/migrations/0007_create_deck_assets.sqlLoading diff…
internal/slides/migrations/schema.sqlLoading diff…
internal/slides/models.goLoading diff…
internal/slides/static/{editor.d193d83bc4c2adaa.js => editor.8282c2af4a08f493.js}Loading diff…
internal/slides/templates/editor.htmlLoading diff…
internal/slidestest/assets_test.goLoading diff…
internal/slidestest/decks_test.goLoading diff…
internal/slidestest/editor_test.goLoading diff…
internal/slidestest/live_test.goLoading diff…
internal/slidestest/testdata/poison/assets.yjsLoading diff…
internal/slidestest/testdata/poison/background.yjsLoading diff…
internal/slidestest/testdata/poison/box.yjsLoading diff…
internal/slidestest/testdata/poison/fonts.yjsLoading diff…
internal/slidestest/testdata/poison/magnitude.yjsLoading diff…