fix: replay the deck log when a derived snapshot is unreadable
internal/slides/decks.go+11 −1internal/slides/snapshot.go+7 −2internal/slides/snapshot_fallback_test.go+73 −0
Intent
<task-notification> <task-id>a983286240d8a6f3c</task-id> <tool-use-id>toolu_01SbNCwN88FAxNknqaVX9PPE</tool-use-id> <output-file>/home/paulca/.cache/tmp/claude-1001/-home-paulca-amadan-net-oficina/c8f62d65-b2d1-4a65-b79e-6a8e82e1e5c6/tasks/a983286240d8a6f3c.output</output-file> <status>completed</status> <summary>Agent "Codex wave C: leftovers" 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. Tree clean, one commit, not pushed. **Commit:** `174a410 fix: slide order is data too (codex 4 at slide level); export status agrees with the held bytes; docs` **Per item:** 1. **`move_slide`** — fixed in wave A's shape, not a second one: helpers renamed to `ordered_maps`/`orderedMaps` + `place_entry`/`placeEntry` (+ new `place_new` shared by `add_slide`/`add_block`); `move_slide`/`add_slide`/`duplicate_slide`/`remove_slide` never rebuild the array; view sorts and hides `order`, so no downstream reader changed. `changes.mjs` had to learn that a slide's own `order` key is a strip re-sync. Tests in Rust, Node and Go (over the wire). 2. **Export status** — page, fragment and API status fall back to the app's hour-long record (`exportFallback`); reasoning in the code and spec §4.3. Chose the fallback over shortening the hold: the other way takes away a promised download. `ForgetExportJobs` is the test seam; the test fails 404 without the fix (verified for both the page and the API arms). 3. **AGENTS.md** — `Handle.Check` beside `deck.Parse`, a reading-order seam, both wasm artefacts + ABI-2 traps, offboarding recheck, `asset_uploads`/migration 0008. 4. **`uploads.mjs`** — false comment corrected; the app mints the staging key, the client's hash is a claim. **Tests:** Rust 30+1+12 / 1+6; Node 149 pass; both wasm `cmp` identical; bundle reproducible (`editor.ea69e9f399e840e0.js`); `make ci` green. Only `every-block.yjs` moved bytes — no `.view.json` moved. **Gate:** full `.amadan/ci.d/10-gate` on a clean `git archive HEAD` of `174a410` → `gate: ok`, `gate exit=0`. **Browser drive:** `ok 61.6s` — fails on `socket()` inside the Bash sandbox, passes with it off (ran it that way). **Concerns:** `duplicate_slide` still inserts at array `i+1` (harmless, untidy); `ForgetExportJobs` reassigns fields without a lock (test seam only); a slide's `order` isn't `Check`-validated, same tolerance the block key has; wave B's `present.js` test and bearer upload gaps remain. Full write-up in `.superpowers/sdd/2026-09-08-slice-3-export/codex-wave-c-report.md`.</result> <usage><subagent_tokens>269028</subagent_tokens><tool_uses>216</tool_uses><duration_ms>1390976</duration_ms></usage> </task-notification>
diff --git a/internal/slides/decks.go b/internal/slides/decks.go| index 901e255..db24e2d 100644 |
| --- a/internal/slides/decks.go |
| +++ b/internal/slides/decks.go |
| @@ -203,7 +203,17 @@ func (a *app) openDeck(ctx context.Context, d Deck) (*core.Handle, *deck.Deck, e |
| if snap != nil { |
| if err := h.Apply(ctx, snap.Bytes); err != nil { |
| h.Close(ctx) |
| - return nil, nil, fmt.Errorf("slides: replay deck %s snapshot at seq %d: %w", d.Sqid, snap.Seq, err) |
| + a.logger.Error("slides: read a deck's newest snapshot, replaying the plain log instead", |
| + "deck", d.Sqid, "err", err) |
| + h, err = a.core.NewDoc(ctx, d.Sqid, serverClientID) |
| + if err != nil { |
| + return nil, nil, fmt.Errorf("slides: open deck %s: %w", d.Sqid, err) |
| + } |
| + updates, err = a.replayLog(ctx, d, 0) |
| + if err != nil { |
| + h.Close(ctx) |
| + return nil, nil, err |
| + } |
| } |
| } |
| for _, u := range updates { |
diff --git a/internal/slides/snapshot.go b/internal/slides/snapshot.go| index 7ecda2b..c038012 100644 |
| --- a/internal/slides/snapshot.go |
| +++ b/internal/slides/snapshot.go |
| @@ -79,13 +79,18 @@ func (a *app) replayPlan(ctx context.Context, d Deck) (*DeckSnapshot, []DeckUpda |
| if snap != nil { |
| since = snap.Seq |
| } |
| + updates, err := a.replayLog(ctx, d, since) |
| + return snap, updates, err |
| +} |
| + |
| +func (a *app) replayLog(ctx context.Context, d Deck, since int64) ([]DeckUpdate, error) { |
| var updates []DeckUpdate |
| if err := a.db.G.WithContext(ctx). |
| Where("deck_id = ? AND seq > ?", d.ID, since). |
| Order("seq").Find(&updates).Error; err != nil { |
| - return nil, nil, fmt.Errorf("slides: read deck %s log: %w", d.Sqid, err) |
| + return nil, fmt.Errorf("slides: read deck %s log: %w", d.Sqid, err) |
| } |
| - return snap, updates, nil |
| + return updates, nil |
| } |
| // maybeSnapshot writes a snapshot when seq lands on the cadence |
diff --git a/internal/slides/snapshot_fallback_test.go b/internal/slides/snapshot_fallback_test.go| new file mode 100644 |
| index 0000000..eb845f7 |
| --- /dev/null |
| +++ b/internal/slides/snapshot_fallback_test.go |
| @@ -0,0 +1,73 @@ |
| +package slides |
| + |
| +import ( |
| + "context" |
| + "testing" |
| + "time" |
| +) |
| + |
| +func TestUnreadableSnapshotFallsBackToAuthoritativeLog(t *testing.T) { |
| + db := testDB(t) |
| + a, _, err := newApp(db, Config{Env: EnvDevelopment, Logger: quietLogger()}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { a.Close() }) |
| + ctx := context.Background() |
| + owner, _, err := ensureUser(ctx, db.G, "snapshot-owner@example.test") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + row, err := a.createDeckRow(ctx, owner, "Preserved deck", defaultTheme) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + opening, err := openingOps("Preserved deck", defaultTheme) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := a.editDeck(ctx, &row, actorFor(owner), opening, nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + batch, err := opsJSON([]op{{"op": "set_meta", "title": "Newest log title"}}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := a.editDeck(ctx, &row, actorFor(owner), batch, nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + want := viewJSON(t, a, row) |
| + snapshot := DeckSnapshot{DeckID: row.ID, Seq: 1, Bytes: []byte{0xff}, CreatedAt: time.Now().UTC()} |
| + if err := db.G.Create(&snapshot).Error; err != nil { |
| + t.Fatal(err) |
| + } |
| + got := viewJSON(t, a, row) |
| + if string(got) != string(want) { |
| + t.Fatal("fallback changed the authoritative deck") |
| + } |
| + var count int64 |
| + if err := db.G.Model(&DeckUpdate{}).Where("deck_id = ?", row.ID).Count(&count).Error; err != nil { |
| + t.Fatal(err) |
| + } |
| + if count != row.Seq { |
| + t.Fatal("fallback changed the update log") |
| + } |
| + var persisted DeckSnapshot |
| + if err := db.G.First(&persisted, snapshot.ID).Error; err != nil { |
| + t.Fatal(err) |
| + } |
| + if string(persisted.Bytes) != string(snapshot.Bytes) { |
| + t.Fatal("read fallback changed the stored snapshot") |
| + } |
| + |
| + if err := db.G.Model(&DeckUpdate{}).Where("deck_id = ? AND seq = ?", row.ID, row.Seq).Update("bytes", []byte{0xff}).Error; err != nil { |
| + t.Fatal(err) |
| + } |
| + h, view, err := a.openDeck(ctx, row) |
| + if h != nil { |
| + h.Close(ctx) |
| + } |
| + if err == nil || h != nil || view != nil { |
| + t.Fatal("fallback concealed corruption in the authoritative log") |
| + } |
| +} |