Skip to content

fix(lifeboard): harden state persistence — audit findings - #320

Open
ivanmkc wants to merge 1 commit into
feat/lifeboard-foundationfrom
lifeboard/10-persistence
Open

fix(lifeboard): harden state persistence — audit findings#320
ivanmkc wants to merge 1 commit into
feat/lifeboard-foundationfrom
lifeboard/10-persistence

Conversation

@ivanmkc

@ivanmkc ivanmkc commented Sep 2, 2026

Copy link
Copy Markdown
Owner

The stack

# PR What it adds Tests at this point
1 #311 A real localStorage for the test suite on Node 25 viewer 747
2 #312 @ivanmkc/termchart-canvas; injectable interact transport canvas 473 · viewer 278
3 #313 lifeboard PWA: fact log, boards, service worker lifeboard 36
4 #314 Shell, family profiles, tap layer lifeboard 82
5 #315 The agent turn, four providers, Connections lifeboard 142
6 #316 Packs, recipes, shopping-list arithmetic lifeboard 231
7 #317 Provenance and lineage lifeboard 257
8 #318 Calendar, Gmail, mail triage lifeboard 301
9 #319 The bridge and long jobs cli 314 · lifeboard 320
10 #310 Proactivity, scheduled boards, Google sign-in cli 320 · lifeboard 343
11 #320 Persistence audit fixes: multi-tab, stale builds, no-storage boot lifeboard 350
12 #321 User guide, bridge setup, QA guide, persistence audit lifeboard 350
13 #322 A plan for third-party integrations (docs only) unchanged
14 #323 A practice drill a board can define: audio + Q&A canvas 485 · lifeboard 372
15 #325 Export and restore lifeboard 389
16 #326 Draft a message, approve it, then send lifeboard 399
17 #327 Hand long work to the Mac lifeboard 406
18 #328 A Packs screen, and authoring one by describing it lifeboard 418
19 #329 Activity — what changed, who changed it, undo lifeboard 428

Every branch in the stack was checked out on its own and verified independently: npm install, tsc --noEmit for every package, npm test across the workspace, and the offline e2e where it exists. All nineteen are green — no branch depends on a later one to build or pass. Full viewer e2e (11 suites, 106 assertions) was run on #312 as the behaviour-preservation evidence for the extraction.


Stack position: 11 of 12. Base: feat/lifeboard-foundation.

An audit of the state persistence mechanism — log.ts, store.ts, transcript.ts, prefs.ts, sw.js — turned up five defects. Each is fixed here with a test that fails without the fix. The full write-up, including the three findings left open, is in docs/lifeboard/persistence-audit.md (added in the next PR).

A second tab's writes were silently lost. Sequence numbers were minted from the in-memory tail, so two connections to the same database produced the same key, IndexedDB rejected the duplicate, and the entry never landed. What a person saw: a tick in the second tab reverting itself a moment after being tapped, with no explanation. Reproduced before the fix:

TAB-B-ERROR:  Error: IndexedDB transaction failed
ROWS-ON-DISK: ["from A"]

Reading and appending now happen in one transaction, and a tab absorbs whatever another wrote before deciding what its own change means — so an update against a record it had never seen is applied rather than dropped.

A new build never reached anyone. app.js is not content-hashed and the service worker was cache-first for every same-origin GET, so a deploy was invisible until someone bumped CACHE in sw.js by hand — which nothing enforced. The shell is network-first with a cache fallback now; content-hashed chunks stay cache-first, where that is free and correct. The e2e serves a changed build mid-run and asserts it arrives.

The app did not start without storage. A private window rejects the open, and the rejection was literally undefined because request errors were passed through unchecked — a blank screen with nothing in the console. There is a Backend seam now, with an in-memory fallback and a durable flag; the app boots and says plainly that it is not saving anything. prefs.ts already degraded this way; the store now matches it.

Transcript writes could reject with nobody listening. Three console writes were fire-and-forget, so a full disk became an unhandled rejection. Losing a line of narration is survivable; losing it silently while the page logs an uncaught error is not.

The cost of a write grew with the age of the household. Every append re-folded the entire log and structuredCloned every record — 0.39 ms per write at 200 entries, 1.57 at 1,000, 4.79 at 3,000, and every tick writes twice. Entries fold into the existing view incrementally now.

Also handles onblocked and onversionchange, which could otherwise leave the open promise neither resolved nor rejected — an app hanging on a blank screen with no error at all.

Left open, and argued in the audit: no export or backup (the largest remaining risk, and the cheapest to fix — the durable state is a JSON array); no schema version on entries; no log compaction.

Verified: lifeboard 350 tests (7 new), offline e2e 18/18, all packages green.

🤖 Generated with Claude Code

An audit of the persistence mechanism turned up five defects. Each is confirmed
by a test that fails without the fix.

A SECOND TAB LOST ITS WRITES. Sequence numbers were minted from the in-memory
tail, so two connections to the same database produced the same key, IndexedDB
rejected the duplicate, and the write vanished -- a tick in the second tab
reverted itself with no explanation. Reading and appending now happen in one
transaction, and a tab absorbs whatever another wrote before deciding what its
own change means, so an update against a record it had never seen is applied
rather than dropped.

A NEW BUILD NEVER REACHED ANYONE. app.js is not content-hashed and the service
worker was cache-first, so a deploy was invisible until someone bumped the cache
name by hand -- which nothing enforced. The shell is network-first with a cache
fallback now: online you get the current build, offline you get the last one that
worked. Content-hashed chunks stay cache-first, where that is free and correct.
The e2e serves a changed build mid-run and asserts it arrives.

THE APP DID NOT START WITHOUT STORAGE. A private window rejects the open, and
the rejection was `undefined` because request errors were passed through
unchecked. The result was a blank screen with nothing in the console. There is a
backend seam now, with an in-memory fallback and a `durable` flag; the app boots
and says plainly that it is not saving anything.

TRANSCRIPT WRITES COULD REJECT WITH NOBODY LISTENING. Three console writes were
fire-and-forget, so a full disk became an unhandled rejection. Losing a line of
narration is survivable; losing it silently while the page logs an uncaught error
is not.

THE COST OF A WRITE GREW WITH THE AGE OF THE HOUSEHOLD. Every append re-folded
the entire log and structuredClone'd every record: 0.4 ms per write at 200
entries, 4.8 ms at 3,000, and every tick writes twice. Entries now fold into the
existing view incrementally.

Also handles onblocked and onversionchange, which could otherwise leave the open
promise neither resolved nor rejected -- an app hanging on a blank screen with no
error at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant