Content temporarily unavailable
Sync metadata session: borrowed laws and independent transactions#
Base revision: 30175be48 (main). Opened by issue #2220. Every lane below is RED on the base revision. The same pull request repairs production; see "Production repair" for the evidence block.
Decisions#
The maintainer made five decisions on 2026-10-22; the fifth replaced an encoding the author had extended without asking. Three more followed on 2026-10-23; the sixth withdrew the second:
Borrow standard theory. "this is a clear case where we borrow laws"; "Generally we want to follow standard theory here". One sync run is a session.
Closed nesting (withdrawn by decision 6). "closed nesting is what the api suggests and what we're partially enforcing so let's go with that". A sync transaction begun while another is open was to be a closed-nested child of the innermost open one.
Read your writes in its standard form. A read includes the source's last surviving write: it returns that write or a write ordered after it.
Seeds are older than every source write. A hydration seed never supersedes a source write to the same object, whatever their arrival order.
D5: a source insert replaces a seed, in either order. The first K4 question asked about a seed arriving while the source's open transaction had inserted the same row. The maintainer answered "yeah seeds are older". The author then extended that answer, without asking, to seeds arriving after the insert committed, and encoded it as timestamp order: the source's insert of a seeded row was an update, so the seed's metadata and fields survived it. A fresh adversarial reviewer showed that this reading went beyond the question asked, broke the monotonic-reads transfer (a read changed from absent to the seed's metadata with no new source write), and adopted a partial merge of stale fields that review R9 of 2026-10-08 had called speculative. The author put two arrival-independent readings to the maintainer: A, a seed as a base layer that the source's insert merges over; and B, a source insert that names the whole row and its metadata and so replaces any seed of that row. The maintainer asked how a seed could arrive after the insert. The realistic case is a HydrationBoundary that renders on a later navigation, while the collection already syncs the row. The maintainer chose "yup, b".
Under B a seed supplies only objects that no surviving source write has written. A source insert replaces a seeded row and its metadata without a duplicate-key error; an update merges over it; an insert equal to a row only a seed supplied is an insert, not an equal re-insert. docs/guides/ssr.md ("Initial Data") is revised to say so.
A third review round found that D5 implied two answers when a seed arrives after a partial source update of the same row: seeds beneath field by field (arrival-independent; a late seed can drop a field under a live row), or ignored once the source has written the row (a partial update merges over the row as it stood). The maintainer chose the second ("b"). A seed that arrives after a surviving source write to an object is ignored for that object; only a partial update's merge base depends on arrival order. Metadata is unaffected, because metadata writes name the whole object. The seed-order property therefore compares consumers' rows only under full row updates. The glossary gains "hydration seed".
Independent transactions (2026-10-23). A first closed-nesting repair passed every oracle in this record and broke eight existing tests in three files: collection-truncate.test.ts, collection-sync-reentrancy-oracle.test.ts, and includes-collection-oracle.property.test.ts. Each relies on a transaction begun inside an open one committing and applying on its own. Handler write-backs, change listeners, and live-query facades share the sync API with the source, and the API cannot tell their begin from a source's nested one. Under closed nesting a handler's confirmation would merge into the source's open transaction, and a handler that awaited its receipt could deadlock. The author put the conflict to the maintainer, with B (independent transactions) recommended. The maintainer answered: "yeah B is fine -- I don't have any real use case for nested transactions to begin with and you're right that to add this properly we'd need clear transaction ids to support the nesting".
Every sync transaction is therefore independent. write, truncate, commit, and abort act on the innermost open transaction. Accepted transactions apply in commit order, and a hold delays application without changing that order. A read orders the session's writes by commit: accepted transactions in commit order, then the open transactions in begin order. An abort discards only its own transaction's writes. An inner transaction still reads the open writes of the transactions it began inside; the author's proposal said so, and the maintainer accepted it.
Cover every nested history (2026-10-23). The first encoding of decision 6 excluded an inner row write or truncate that conflicts with an enclosing open transaction's row writes. The maintainer asked to "cover everything with the grammar". The model now judges those histories. An open transaction's inserts follow the rows before them in commit order; a commit fixes their kinds. An outer insert that lands on a different row the source wrote makes its transaction invalid, as a duplicate-key insert would. No read includes an invalid transaction, it ignores later writes, and its commit rejects. Two rules came from existing authority, not from the maintainer. Later writes cannot revive an invalid transaction, a truncate included: core's own comment says so ("later writes cannot revive it"), and core's truncate contradicted it. A seed accepted after an insert was written cannot change the insert's kind, because a seed is older than every source write (D4).
Cached rows are seeds, and wrapper inserts replace (2026-10-23). The persisted wrapper wrote every source insert to core and storage as an update. The SQLite persistence plan (PERSISTNCE-PLAN-SQLITE-ONLY.md, 2026-02-10, §D) gives the reason: a remote snapshot that overlaps the local cache must not raise DuplicateKeySyncError. Under partial row updates the update also merged a cached row's omitted fields under the insert. No document argued for that merge; the test that pins it (#2037) calls it pre-existing behavior. The maintainer chose option C: a cached row is a snapshot older than every source write, like a hydration seed, so a source insert replaces it and its metadata. While the change was being implemented, three existing persisted owners showed that the wrapper's inserts also replace rows the source itself wrote earlier in the run (the hydration FIFO and hydration-failure tests, and the persisted optimistic-history oracle). The maintainer then chose C1: in the wrapper, an insert replaces whatever row it lands on, never raises a duplicate-key error, and never invalidates its transaction. Its metadata still follows the 2026-10-05 rules: it clears unless it is an equal re-insert of a row the source wrote. Core keeps duplicates and invalidation.
Cached rows are seeds in core too (2026-10-26). The prep-PR review found that a later DbClient seed could not replace a persisted cache row: the wrapper loaded cached rows into core as ordinary adapter work, so core counted them as source rows. The maintainer confirmed that cached rows are seeds there too ("yeah they're seeds too"). A cache load happens at preload, so a DbClient seed arrives after it and wins, as a later seed does. The same review confirmed the metadata law that a kept value is not a write: an equal re-insert keeps metadata as an update does, so the latest seed still governs it until the source writes it.
A merged write can carry a later seed into an insert's kind (2026-10-26). Decision 7 said a seed accepted after an insert was written cannot change the insert's kind. Decision "b" lets that seed change a partial update's merge base. A random persisted campaign found the two meeting: an outer equal re-insert, then a later seed, then an inner partial update that merges over the seed and commits ahead. Core and the wrapper re-judged the re-insert against the row the inner update left, which differs, so core invalidated the outer transaction and the wrapper cleared its metadata; the model kept the re-insert. The maintainer chose production's reading ("2"): an insert's kind follows the row it lands on. A later seed alone does not change it, and neither does an open transaction's own write over the seed, but a transaction that commits after the seed and merges over it does.
A peer's write in this run is a source write (2026-10-26). A second high review found that rows a peer's source wrote in this run, which reach this tab through storage, were treated as seeds or skipped behind a DbClient seed. The maintainer adopted the recommendation that follows D4: such a row and its metadata are source-written for this session. It replaces a DbClient seed this tab holds, a later seed does not supersede it, and an equal re-insert of it keeps metadata. The rule of origin/main that "DbClient hydration seeds remain source-owned until confirmed by the source" now yields to a peer's notice of a write in this run; rows from a previous run stay seeds (decision 9). Eighteen persisted-oracle cases that held the seed against a peer's targeted notice were rewritten to this rule. Asked about them, the maintainer answered "LWW is a good general heuristic here": among source writes, the last one wins, and a seed is older than every source write (D4).
A source transaction may stay open across a storage read (2026-10-26). The new read-hold lane found that a commit threw "a persisted sync transaction cannot cross a hydration cycle" when an on-demand storage read started while the transaction was open. The maintainer chose to support it: a transaction binds to a storage read when it commits, not when it first writes, and writes during a read follow the same metadata rules as other writes: the last write for a key wins, so an insert without metadata clears an explicit write before it and a cached row's metadata. The wrapper's earlier rules, that such an insert kept an explicit write before it (on every path) and kept the metadata of rows the read supplied, and that a commit crossing a read threw, are retired; ten persisted-oracle cases were rewritten.
Unknown authorship keeps what this tab holds (2026-10-26). After a full-reload notice, a missed notice, or a truncate, this tab cannot tell which stored rows a source wrote in this run. The third high review asked which way that should cut. The maintainer first chose to count every such row as written, then, shown that a full reload would then delete or roll back every held DbClient seed that storage lacks or holds older (the persisted oracle's keeps a DbClient hydration seed across … peer notice cases), chose the rule the code already followed: a seed this tab holds stays, and a stored row this tab did not hold counts as a source write, so a later seed cannot replace it. A peer's write to a key this tab holds as a seed stays hidden until a notice names the key or the source writes it. Decision 15 extends the rule to every stored row.
A commit fixes its inserts' kinds as it read them (2026-10-26). The fourth high review asked whether an inner transaction's insert, judged against an enclosing open transaction's write it read, should be judged again against the row it lands on when it commits ahead. The maintainer kept the model's rule: a commit fixes its inserts' kinds in the order as the transaction read it, so an inner equal re-insert of the enclosing row keeps metadata and replaces the row it lands on. 30175be48 already let an inner insert over an enclosing open truncate replace the row it lands on. Decision 10's "an insert's kind follows the row it lands on" concerns the inserts of transactions still open.
Under unknown writes, only a changed row is a source write (2026-10-26). Decision 13 left the code's two paths disagreeing about stored rows when this tab cannot tell what a source wrote: a reload counted every stored row and its truncate as source writes, so later seeds were ignored for the rest of the run, while a validation read left unchanged rows alone. The maintainer chose the second: a stored row that differs from the one this tab holds was written; an unchanged seed stays one; a dropped row is a source's delete; and a reload's truncate is not a source truncate. A reset still is.
Row metadata of a missing row stays until a source deletes the row (2026-10-27). The peer lane found begin rowSet(2) commit peer(1,row): SQLite stores row metadata on its row, so metadata a source writes for a row that does not exist is not durable, and a reload deleted it. Whether a notice reloaded decided whether it survived. The maintainer chose option C: this tab keeps such metadata for the rest of the run unless a source deletes the row; a restart loses it, as it was never durable. Three persisted-oracle cases that pinned the deletion (… orphan row metadata after a no-demand peer reload, twice, and the orphan check of does not republish unchanged rows for a repeated reconciliation notice) were rewritten.
A write over a DbClient seed stores the row this tab shows (2026-10-27). seed(2) begin upsert(2) commit peer(1,row): seeds never reach storage, so an update merged over a seed stored only its own fields, and a reload or restart dropped the seed's fields. The maintainer chose option B: the first update a transaction makes to a row a DbClient seed holds stores the seed's row merged with the update.
A reload after a named notice keeps a seed's metadata (2026-10-27). seed(2,narrow) begin upsert(2) commit seed(2) peer(1,row): the update kept the first seed's metadata, and a later seed replaced it in this tab (decision 9), but storage cannot follow a seed, so a reload put the first seed's metadata back. The maintainer chose option B: metadata no source wrote in this run stays as this tab shows it across reloads, and decision 17 stores only the row. Asked whether that holds after a full-reload notice too, where two main cases expect stored metadata to win, the maintainer chose the scoped form, B′: decision 18 governs reloads after notices that name their keys; when this run's writes are unknown, stored metadata that differs is a source write (decision 15).
A reset's treatment of held seeds depends on the sync mode (2026-10-27). M4 found that an on-demand reset beside a held DbClient seed skipped its truncate, so metadata of a missing row and rows a hold kept from base survived it. Asked which rule governs held seeds, the maintainer chose the mode-specific rule after a corrected premise (eager resets already truncate first): eager, a reset is a plain source truncate, seeds included; on demand, it keeps held seeds' rows and metadata until the replayed demand replaces them, so one publication replaces them without an empty view.
An on-demand reset is a source truncate that keeps held seeds (2026-10-27). The new lane then found a seed of row 2 that arrived after an on-demand reset beside a held seed of row 1: production applied it, because the reset deleted only rows it saw, while with no held seed the reset truncated and rejected the same seed. A reset that skipped its truncate also skipped the demand replay that the truncate drives. The maintainer chose a source truncate that exempts held seed keys: they keep their row, metadata, seed status, and seed authority; every other key is cleared and closed to later seeds; demand replays. A first answer ("seen objects only") was withdrawn after a corrected premise.
A persisted Collection accepts a source transaction when it hands it to core (2026-10-28). H2 needed a late abort: a commit whose signal aborts while the wrapper still buffers it behind a storage read. SyncConfig.commit says that aborting after acceptance has no effect. The maintainer first chose that text, but it failed 16 cases of the abort-graph law ("abort graphs preserve independent siblings and reject dependent suffixes") and its witnesses, which the question had not named. Asked again, the maintainer kept the abort-graph law: the wrapper's acceptance is its handoff to core, so a signal aborted before then abandons the transaction, its receipt rejects, and source reads include it until then.
A seed is judged when it arrives (2026-10-28). The reads-and-seeds lane found a seed that arrives after a buffered write to its row, which is then abandoned. The glossary's "surviving source write" would revive the seed; production ignored it for good. The maintainer chose judging at arrival, for less code in a tiny window: the seed stays ignored for that object. The glossary's hydration-seed entry says so.
Laws and sources#
| Law | Source | Transfer to a sync run |
|---|---|---|
| Read your writes | Terry et al. 1994, §3.1 | Every read includes every accepted write of the run and every write of its open transactions, ordered by commit: accepted transactions in commit order, then open transactions in begin order |
| Monotonic writes; one current value | Terry §3.4; Moss 1981, p. 61 | A transaction's writes take effect at its commit. The last write in commit order, issue order within a transaction, is the value, for reads and for what applies |
| Monotonic reads | Terry §3.2 | The writes a read includes only grow, except at an abort. When an inner transaction commits, an enclosing open transaction's write to the same object is ordered after it, so a read can change without a new write |
| Own reads | Adya 1999, §3.1.2 | A transaction reads its own writes; the innermost open transaction's writes come last in the order |
| Aborted reads | Adya §3.1.2, G1a | Not guaranteed across open transactions: an inner transaction reads an enclosing transaction's open writes and can commit before that transaction aborts (decision 6). No read after an abort includes the aborted writes |
| Invalid transactions | decision 7; core's invalidation comment | A transaction whose insert lands on a different source row, in commit order, is invalid: no read includes it, later writes are ignored, and its commit rejects. Where inserts replace (the persisted wrapper, decision 8), the insert replaces that row instead |
| Termination order | the sync API | commit and abort act on the innermost open transaction; the API has no transaction handle |
| Monotonic atomic view | Bailis et al. 2014 (HAT), Def. 27 | Once a consumer observes any effect of a transaction, it observes all of its effects thereafter, across both stores. The comparison is stronger: collection.base must equal the model at every checkpoint |
| Applied receipt | SyncConfig.commit docs; glossary "applied receipt" | A receipt settles when its transaction applies |
| Sticky availability | HAT §5.1.3 | The persisted wrapper and core form one session |
| Thomas's write rule | Thomas 1979; Bernstein, Hadzilacos, and Goodman 1987, §4.2 | A seed's snapshot is older than every source write, so it is ignored for any object a surviving source write has written, in either arrival order |
Writes-follow-reads and PRAM follow from read-your-writes, monotonic reads, and monotonic writes under commit-order apply; the oracle does not check them separately. Two transfers are excluded. Session guarantees are per session, so consumers (collection.base) keep the applied view. Adya allows version order to differ from commit order; TanStack DB chooses commit order.
The research survey read the primary texts locally and verified 13 load-bearing quotes against the text or page images. The Thomas's-write-rule row names the textbook rule; those texts were not read for this record. Moss is the only primary source on nesting. Lynch, Merritt, Weihl, and Fekete's formal treatment was not accessible.
Executable owner#
packages/db/tests/sync-metadata-session-oracle.ts holds the contract, an event-log model, the history grammar, the driver contract, and the comparison. Drivers:
- sync-metadata-session-oracle.property.test.ts: a DbClient Collection; lanes flat, nested (depth ≤ 3), seeds, nested-seeds, seeds-full (rowUpdateMode: 'full'), and nested-conflicts, which favours row writes to one row inside nested transactions so that inner writes land ahead of enclosing ones and transactions become invalid.
- db-sqlite-persistence-core/tests/sync-metadata-session-persisted.test.ts: persistedCollectionOptions over an in-memory adapter, with store and core holds; lanes persisted-flat, persisted-nested, persisted-seeds, persisted-cached (row 1 is in storage when the run starts; the model holds it as a seed accepted before any action), persisted-conflicts, persisted-nested-seeds, and persisted-reads, whose read hold keeps an on-demand storage read in flight while the source writes and commits (decision 12). The last crosses DbClient seeds with nesting and the cached row, with the conflict lane's weighting; its keepsMetadata flag favours writes that keep metadata over writes that clear it, without excluding any action, so a later seed often meets metadata that an accepted re-insert kept. Every persisted lane declares the one model difference of decision 8: an insert over a different row replaces it.
The row-metadata composition oracle keeps its base nested lane, which already judged independent transactions: a transaction begun inside T1 applies first, and T1's inserts are reclassified against the rows it leaves. This change adds a held variant of that lane (a hold cannot change commit order) and applies D5 to its seed variant. Its held-window lane, added earlier for this issue, stays.
The grammar generates every nested history (decision 7). The key independence property skips histories in which the model invalidates a transaction: invalidation is per transaction, so a row 2 conflict also removes row 1's writes. That is atomicity, not a cross-key leak.
RED evidence on 30175be48#
| Witness | Law | Observation |
|---|---|---|
| hold, begin, collectionSet(cursor), commit, begin | Read your writes | cursor reads absent in the open transaction |
| hold, begin, rowSet(1), collectionSet(gc:a), commit, begin | Read your writes | row and prefix list miss the held writes |
| hold, begin, rowSet(1), begin, rowSet(1), commit, commit, release | Commit order | the held inner commit stays after the open outer transaction: the read returns the inner write, and the release applies the outer write first, so the inner write wins |
| hold, begin, begin, rowSet(1), commit, rowSet(2) | Termination order | the outer transaction's write throws SyncTransactionAlreadyCommittedWriteError |
| begin, rowSet(1), commit, seed(1) | Seeds are older | the seed's metadata replaces the source's metadata-only write |
| persisted: hold(core), begin, upsert(1), commit, begin, commit, begin, collectionSet(cursor), commit | Read your writes | row 1's metadata reads absent; the store-hold neighbour passes |
| persisted: hold(core), begin, collectionSet(cursor), commit, begin, upsert(1), commit | Read your writes | the cursor reads absent once the wrapper has handed both transactions to core; the store-hold neighbour passes |
| persisted: begin, rowSet(2), upsert(2) | Own reads | the insert's implicit clear is not visible in the open transaction |
| begin, upsert(1), seed(1), commit | A source insert replaces a seed (D5) | production turns the open insert into an update over the late seed, so the seed's metadata survives the insert's clear and its other field survives in the row |
| seed(1), begin, reinsert(1) | A source insert replaces a seed (D5) | core treats the insert as an equal re-insert and keeps the seed's metadata; the persisted wrapper clears it, as D5 requires |
| composition nested lane, immediate and held | D5 seed variant; commit order under a hold | both variants fail; the held variant also applies the open outer transaction before the inner one |
| retention: keeps an accepted insert when it commits ahead of an open truncate (held: true) | Commit order | the inner insert applies after the outer truncate, so row 1 survives |
Witnesses written for closed nesting and then revised for decision 6 pass on 30175be48, which already applied independent transactions in commit order when nothing was held: outerAbortKeepsInnerCommit and innerTruncateAppliesAtItsCommit. The run against the base production files (sync.ts, state.ts, client.ts from 30175be48, with this branch's tests) fails 51 of 186 tests in the session, composition, and retention files.
Calibration#
A temporary commit-order reader (walk every pending transaction newest first, stop at a truncate for rows, merge list() oldest first) made the core flat lane and both issue witnesses GREEN in three runs. The same edit left the nested and seed lanes RED, as their laws require more than a reader change. The edit was reverted.
With that reader, a mutant that ignores the truncate stop fails the composition held-window lane's truncated variant, and a list() that merges only the newest transaction fails both held-window variants.
A bounded exhaustive model check (outside the repository) found that readers which omit ancestors or read only the newest pending transaction violate the laws. It also judged today's independent nested apply against closed nesting; decision 6 superseded that part.
The campaign reach test requires each lane to reach its declared states; "Production repair" lists the counts.
A mutant gap hunt against the commit-order reader baseline, in a separate worktree: walking row reads oldest first, ignoring the row truncate stop, and merging list() newest first fail at the comparison. Walking collection reads oldest first fails both flat campaigns but survives the named witnesses and the composition held-window lanes. Returning a delete write's missing value is equivalent: a delete carries no value.
Adding the receipt observation kept the core flat lane GREEN with the commit-order reader in three runs.
Seeds. Under the first K4 encoding (reading A), begin, upsert(1), seed(1), commit passed on production, and a mutant that derived the implicit metadata write from the original insert failed it. Under D5 the expectation reverses, and that witness is RED. While encoding A, the model briefly judged the insert's row presence after its own row write, which turned the wrapper's implicit-clear witness falsely GREEN. Under D5 the clear is an ordinary write again, and the witness is RED.
Paired formulations. Key independence and seed order fail on 30175be48 without consulting the model's reads.
- Key independence, seeds lane: hold, seed(1), seed(2), begin, cancel. Row 1's held seed reads absent once row 2's seed follows; without seed(2) it reads as seeded. This is the issue's newest-transaction read in a cross-key form.
- Seed order: a seed that arrives after a source's metadata write replaces it, while the same calls with the seed first keep the source's write. Under reading A, a reviewer found that a reordered run could throw on an equal re-insert, a false positive. Under D5 the insert replaces the seed in both orders, so the re-insert stays equal, and a throw would be a D5 violation.
- Calibration: with the commit-order reader, flat key independence passes. A mutant reader that stops at the newest transaction touching any row's metadata fails it on both flat campaigns. Nested key independence fails on the known closed-nesting throw. No correct production exists yet to calibrate seed order GREEN; with every seed already first, its two runs perform identical calls.
Range (ORC-004). Two edge cases from outside the design work were added.
- Collection keys gc: (equal to the prefix) and gc (a proper prefix of it) needed only new parameter values. With the commit-order reader, the core flat lanes and both issue witnesses stayed GREEN in three runs. The probe exposed one oracle artifact: the model listed keys in declaration order while the drivers sort them, which only matched for the old key set. The model now sorts.
- A seed that carries a field the source never writes exposed a missing rule: under rowUpdateMode: 'partial', a source update merges over the row before it, a seed included. The model now merges updates; an insert replaces the row (D5). updateOverSeed checks the merge and updateOverSeedFullRows checks replacement under a seeds-full lane; both pass on production.
Production repair#
The repair keeps the existing structures and changes these rules.
- Reads. metadata.row.get, metadata.collection.get, and list() walk every pending transaction, newest first, then applied state; a truncate hides older row metadata. The persisted wrapper reads every open transaction, innermost first, then its staged transactions, then core.
- Commit order. acceptSyncTransaction places a committed transaction after every accepted one and ahead of every open one; seeds use the same method. An inner commit during a hold therefore no longer leaves the queue's last transaction committed, so the outer transaction can still write and commit, and a release applies in commit order. A replay keeps an accepted transaction's operation kinds: an inner insert written over an outer open truncate replaces the row it lands on, as 30175be48 already did when nothing was held. An open transaction's inserts still follow the rows before it and can make the transaction invalid.
- Seeds (D4, D5). Row data and row metadata are judged separately against committed adapter work, including a wrapping sync's staged committed transactions. A seed replaces earlier seeds whole. A source insert over a row only a seed supplied is an insert; over a seeded row the source also wrote, it stays the re-insert the source wrote, so a later seed beneath an open update cannot make it a duplicate. An insert names its whole row even as an equal re-insert. A held seed's receipt has a rejection handler.
- Wrapper metadata. An insert without metadata stages a clear unless it is an equal re-insert of a row the source wrote; the last write for a key wins.
- Query. getRowMetadata no longer falls back to applied metadata, which read a staged clear as "no value".
- Invalid transactions (decision 7). Core's readers skip a transaction a replay invalidated, and write and truncate ignore it; truncate no longer clears the invalidation. Its metadata writes need no check: no read includes it, and it never applies (a mutant that lets them through is equivalent).
- Seed authority across a hold. The replay projection records which keys accepted source transactions wrote. An accepted source write makes a row the source's, as its application does, so a seed no longer exempts that row while the transaction is held. Before, an inner commit during a hold kept the outer transaction valid, and the same history without the hold invalidated it.
- Wrapper inserts (decisions 8 and 9). A source insert replaces the row it lands on. The wrapper judges its metadata when the source writes it and hands it to core as an update that names the whole row (originalSyncType: 'insert', core's own form for an equal re-insert), so core replaces the row and only the wrapper's metadata writes change its metadata: an insert that clears staged a delete, and an equal re-insert keeps the value without writing it. Before, the wrapper wrote the kept value back as explicit metadata, which made the source its owner, and a delete then the insert over a different row. In storage it is an insert mutation, which the SQLite adapter upserts. The wrapper records the keys its source transactions handed to core in this run; any other row came from the cache or a seed, so an insert equal to it still clears metadata. After an inner commit, the wrapper re-judges each enclosing transaction's implicit metadata clears on the keys the inner transaction wrote, as core's replay does, and an inner re-insert keeps its metadata when it lands where the row is absent. The wrapper keeps, per key, every insert without metadata since the last write that named the key's metadata, and re-judges them all: if any no longer re-inserts the row it lands on, the key's metadata clears; otherwise the write before them stands. An update without metadata keeps that judgment open, because the inserts before it still decide the row's metadata.
- Duplicate inserts. A seed exempts a source insert from the duplicate check only over a row that only seeds supplied; over a row the source wrote, it is judged like any insert, whatever holds or seeds there are. In the replay, a seed beneath an open insert of a row the source also wrote keeps that insert's kind.
- Cached rows (decisions 9 and 11). A storage read marks a row it applies as a seed unless core holds it as a source row or a source wrote it in this run, which the runtime records from this tab's handoffs and peers' notices (a truncate or a full-reload notice makes every stored row count as written). The read stays one transaction; core keeps seed status only for its seed keys, and every other write of the read, a validation delete included, is adapter work. A DbClient seed holds against stored rows only until a source writes its key in this run.
- Writes during a storage read (decision 12). They follow the ordinary metadata rules, and a transaction binds to a read when it commits.
Evidence block#
Law: read your writes, monotonic writes in commit order, own reads, monotonic atomic view, applied receipts, invalid transactions, and D4/D5 seed authority, as stated above, with the wrapper difference of decision 8. Authority: decisions 1 and 3–8.
Reach: declared with createOracleReach in each fixed campaign (ORC-015); a campaign fails below its minimum. Core lanes: 11, or 5 for states that need a seed and a source write on one row, or a write after an invalidation. Persisted lanes: 5. The prep-PR round added the states duplicate source insert (and its hold and seeded forms) and seed under a kept re-insert, and raised the seeds and nested-conflicts campaigns to 150 and 450 histories; every fixed campaign meets its minimums. Counts in the fixed campaigns before that round:
State flat nested seeds nested-seeds seeds-full nested-conflicts read with two held transactions 18 37 16 20 12 65 open transaction over a held transaction 51 61 36 47 30 136 truncate during a hold 30 31 22 24 15 69 nested commit 59 52 158 nested commit during a hold 43 36 107 abort keeps an inner commit 11 9 24 inner write to an enclosing row 7 10 92 transaction invalidated 2 28 commit of an invalid transaction 2 15 write to an invalid transaction 1 16 seed under a source insert 37 27 24 seed under an open update 10 5 7 nested-conflicts runs 300 fixed histories; the others run 100. Persisted lanes: persisted-conflicts (150 histories) reaches an insert that replaces a different row 18 times and an inner write to an enclosing row 50 times; persisted-cached (80) reaches a source insert over the cached row 25 times and a source update over it 14 times.
RED:
- 30175be48 production with this branch's tests, as listed under "RED evidence": 51 of 186 tests fail in the session, composition, and retention files.
- Decision 7, on the production before its repair: 7 failures in the session oracle (reads include an invalid transaction's staged metadata and a collection write made after its invalidation; a hold changes whether a seed exempts a row). Two more were model and grammar faults, fixed before the production change: key independence compared histories that invalidate a transaction, and the model kept a transaction's row writes from before its own truncate.
- Decision 8, on the wrapper before its repair: the persisted-cached lane fails because a source insert merges over the cached row and keeps its seeded field; the persisted lanes fail on nested conflicts.
GREEN: packages/db: 286 files, 13,917 tests pass; the session oracle file passes 78 of 78 twice. packages/db-sqlite-persistence-core: 33 files, 1,732 tests pass (2 skipped, 2 todo); the persisted session file passes three runs in a row. Query DB, node, browser, and Electric suites pass with their receiving expectations updated for decision 8.
Mutant: each fails the oracle.
- Commit in place (no commit-order move): 17 failures, including commitOrderDuringHold, outerContinuesAfterInnerDuringHold, nested hold invariance, and the nested campaigns.
- Replay reclassifies accepted transactions and throws on a duplicate, as 30175be48 did: both variants of the retention witness. A variant that marks the accepted transaction invalid without throwing changes only the internal projection and survives; no public checkpoint observes it.
- Seed exemption only for seed-only rows: seedUnderOpenReinsert. The fixed campaigns do not catch it; random campaigns did, twice, before the witness existed.
- An equal re-insert that merges under partial row updates instead of replacing its row: seedUnderOpenReinsert.
- Readers that include an invalid transaction: the nested and conflict campaigns. A truncate that revives an invalid transaction: the nested and conflict campaigns and the conflict lane's reach.
- Wrapper, earlier rules: reading only the innermost open transaction; keeping an earlier explicit write over an insert's clear; clearing on every insert; hiding staged transactions from seed authority. Each fails at least one persisted lane.
- Wrapper, decision 8: an insert that merges over a held row (12 persisted hydration cases and the hydration FIFO test); storage that receives inserts as updates (the sync-config and hydration cases); a cached row counted as source-written (persisted-cached); enclosing implicit clears left unjudged (persisted-conflicts). An inner re-insert that loses its kept metadata survives every campaign; the pinned witness keeps an inner re-insert's metadata when it commits ahead of the row it re-inserted kills it.
- Decision 9 (prep-PR round): the original handoff on 743a6ce14 fails all four new witnesses and persisted-nested-seeds; writing the kept value only when core would not keep it fails the inner re-insert witness; loading cached rows as source rows fails the cached-row witness and persisted-nested-seeds; a core that ignores a wrapper's originalSyncType merges over seeded rows and fails persisted-seeds and persisted-cached; dropping the insert's judgment at a later update fails rejudges an enclosing re-insert that a later update kept; re-judging only a key's last insert fails the two chain witnesses.
Limits: under partial row updates the seed-order property drops reinsert from both runs, because decision "b" lets a moved seed change a re-insert's planned row. Key independence skips histories that invalidate a transaction. The persisted-reads and persisted-peers lanes' adapter keeps durable rows, so storage reads and reloads within a run are exercised there; the other persisted lanes' cached row is readable only until the run's first durable commit. Since the eighth round, a peer is a grammar dimension of persisted-peers, played by the driver through the coordinator (see "Eighth round"). The error an invalid commit rejects with, and restart and resume, stay outside this oracle.
Code weight#
Final head d1166fe6f, against the merge-base 4c39add29: app grows 232 bytes gzip, @tanstack/db 523, db-sqlite-persistence-core 1,331, and query-db-collection shrinks 21. Each fix commit since the eighth round, measured against its parent:
| Commit | app | db | persistence |
|---|---|---|---|
| d3847dbb6 notice-carried reload | 0 | 0 | −6 |
| e4fb7a71d decisions 19 and 20 (reset truncate, kept seeds) | +76 | +134 | +76 |
| 3b1973276 per-key unknown writes | 0 | 0 | +151 |
| 18f7df562 drop read knowledge from unknown writes | 0 | 0 | −30 |
| 4b6081698 H1, H2, H5, H6, decisions 21 and 22 | +28 | −15 | −31 |
The rest of the change from the eighth round's figures comes from the merges of origin/main. The app growth since the eighth round is the source truncate that a reset opens (sourceTruncate, truncatesSourceKey) and the H1 reader check that uses it. The H5 and H6 dead-code removals paid for the H2 move of noteRunWrites after the commit.
Eighth round, against 7e46f81ad: app grows 125 bytes, 20 fewer than before, because _syncedRowMetadataKeysOutsideBase left the Collection class; the staging check for preserved keys and the metadata reader's check for a status-keeping truncate cost less than it saved. Persistence reaches +1,206 (204 more): the stored-row status rule, whole-row reload writes, the seed bases that decision 17 stores, and the held-row checks.
pnpm size:compare against 427a7c31e (origin/main after the second merge): the typical app scenario grows 126 bytes gzip. The second high review's repairs cost app 9 bytes: the internal whole-row marker and readers that skip invalidated transactions in place. The marker is a bare Symbol(), because its Symbol.for description string alone cost 31 bytes and the wrapper imports the symbol from @tanstack/db. Seed status became a key set on every seed transaction (a DbClient chunk passes its keys), so per-key storage reads cost no extra check. @tanstack/db as a whole package grows 375, db-sqlite-persistence-core 849, and query-db-collection shrinks 14. In persistence, the second high review went from 876 to 699 because removing the operation walk and the hydration-deferred metadata path paid for its rules; the third added 150 for the per-object record of writes in this run, judging open inserts again after internal writes, and the begin guard. app did not change in the third.
The seventh round: against 6c1ca8f8f, app grew 124 bytes, two fewer than before, because the projection's accepted-source key set became a local of its rebuild, and persistence 933 (84 more: the internal apply's written keys and the first reload repair). Against b8d8ee07c, after two merges, the pushed head measures app +135 and decision 15 adds 10 more (+145): core's truncate keeps authority and seed status for a transaction that marks seed keys. Persistence reaches +1002 (69 for decision 15: the shared stored-seed rule and the dropped rows' deletes). A first repair that validated reloaded rows in place, with no core change, failed the three exact-shape witnesses, because only a truncate republishes rows that change events consider equal.
Earlier rounds: the private-member mangle cache now covers the branch's new private methods. Removing Query's owner supplement saved 9 bytes, and tracking peer writes and judging enclosing inserts again after an abandoned transaction added 91 to persistence.
The prep-PR round changed these numbers. Decision 9 costs core 12 minified bytes (a wrapper's originalSyncType passes through write), paid for by the state rebuild that reset() now reuses; app stayed at +111. The duplicate-insert repair (B, C) adds 14 bytes to app. H first added 19 bytes as a delete-and-return pair of set updates; merging the two DbClient key sets into one map cut that to 6. In persistence, decision 9 removed the kept-value lookup and the delete-then-insert handoff and added the cached seed marking and the per-key chain of implicit inserts (+83); the per-transaction staged rows that make a write-time read linear (D) add the rest. Each addition has a witness or mutant that fails without it.
- Core, paid by every app: the readers' loops over every pending transaction and their skip of invalid transactions, commit-order acceptance, the source-key sets that tell a seed-only row from a source-written one, and the insert-replaces-its-row rule.
- DbClient only: per-object seed authority, including a wrapping sync's staged transactions. It lives in client.ts, so an app without DbClient does not ship it.
- Persistence only: the staged-row fold, the re-insert judgment, the record of implicit metadata clears and their re-judgment after an inner commit, the source-written key set, and the insert handoff to core and storage. Each has a mutant that fails a persisted lane or witness.
Refactors made to pay for it: the seed authority lookups moved out of the state class into client.ts (app +240 to +111); the seed acceptance method became the single acceptance method for commits and seeds; the two seed authority lookups became one loop; the base invariant throw for an accepted duplicate is gone; guards on an invalid transaction's metadata writes were removed once a mutant showed them unobservable; decision 8 removed the wrapper's invalidation and duplicate-key paths. Two refactors were tried and reverted because they grew gzip: one shared reader for row.get and collection.get (gzip compresses the two loops better than the helper), and moving the wrapper's deferred-key predicate into core (it moved bytes from persistence into every app).
Review ledger#
Three loss-audit scanners and three oracle reviewers examined this change.
| Finding | Disposition |
|---|---|
| G1a was cited as permitting child reads | Fixed: the laws table cites relative commit and delayed commit |
| Monotonic atomic view dropped "thereafter" | Fixed: stated, with the stronger per-checkpoint comparison |
| Writes-follow-reads and PRAM uncited | Fixed: stated as consequences, not checked separately |
| Receipt checkpoint for a nested child undefined | Fixed: receipts are an observation; the law follows from the existing receipt contract |
| Adjacent form F2 (nested truncate during a hold) not pinned | Fixed: witness added |
| Adjacent form F3 (consumer sees a seed, then the source) not pinned | Fixed by D5: the order is legal, and seedUnderOpenInsert checks it |
| Source and consumer seed layers differ without explanation | Fixed: model prose explains the two layers |
| Reach test does not ablate generator dimensions | Fixed: see "Reach ablation" below |
| Derived checks (hold invisibility, unrelated-action invariance) and the bounded model check live outside the repository | Fixed: three production-against-production properties (hold invariance, key independence, seed order); the bounded model check stays outside |
| Mutant "collection reads oldest first survives" | Refuted on re-run: both flat campaigns kill it |
| The issue's held-cursor history passed through the wrapper | Checkpoint artifact: actions ran back to back, so transactions stayed in the wrapper's staging. With continuations drained between actions (the receipt flush), the core-hold variant fails |
| list() presence of a key set to undefined | Fixed: core lanes store undefined; get reads it as absent and list() keeps it |
| Equal re-inserts not generated | Fixed: reinsert action |
| Reach ablation | Fixed: removing each axis removes its regime |
| No second formulation in the repository | Fixed: hold invariance compares production with and without holds |
| Nesting and seeds through the wrapper not driven | Fixed: persisted-nested and persisted-seeds lanes |
| Stale runs and writes without a transaction | Fixed: boundary witnesses |
| Restart and resume reads | Owned elsewhere: SQLite resume-snapshot and Electric resume owners |
| Truncate across several keys | Covered: the session model has two row keys; truncate clears both |
Second review round#
Three fresh reviewers examined the changes made after the first round: an adversarial review of the seed laws, a mutant gap hunt in a separate worktree, and a tension scan of the Query witnesses.
| Finding | Disposition |
|---|---|
| The K4 encoding extended the maintainer's answer to committed inserts, broke the monotonic-reads transfer, and adopted the partial merge that R9 called speculative | Fixed by decision D5 (reading B), asked of and chosen by the maintainer |
| runSeedOrderInvariance could throw on an equal re-insert after reordering | Fixed by D5: an insert replaces a seed in both orders, so the re-insert stays equal |
| Seed-order invariance cannot choose between arrival-independent readings | Accepted: the witnesses and D5 carry that choice; the property checks arrival independence only |
| The hydration-authority owner runs only full row updates and no metadata | Fixed: it crosses full and partial modes, adapter inserts and updates with and without metadata, and seeds carrying metadata and an extra field, initialData rows with an extra field, and a metadata-only source write; it reads metadata through client.dehydrate(). After the third review round, 19 of its 52 grid cells and one pinned production case fail on 30175be48: an insert keeps a seed's metadata or merges its field (including over initialData); a late seed's metadata is dropped after an update that wrote none; a later seed overwrites a metadata-only source write; a second seed merges over the first; a metadata-less later seed keeps the earlier seed's metadata |
| Seed-to-seed merge versus replacement is not distinguished | Fixed: narrow seeds omit the extra field; laterSeedReplacesSeed fails on 30175be48, where a second seed merges over the first |
| Reader mutants for a missing truncate stop, list() ignoring a pending delete, and prefix matching that is too loose or too strict died only in generated campaigns | Fixed: the named witnesses openTruncateHidesHeldRow, openDeleteHidesHeldListEntry, and prefixBoundary each kill their mutant and pass with the commit-order reader |
| A full-mode update merged only in the accepted projection survived: no checkpoint observes the accepted row | Fixed: an upsert that names metadata carries a field m, so row shapes differ; fullUpdateThenEqualInsertDuringHold kills the mutant with a duplicate-key throw at the equal re-insert, and production passes |
| A merge keyed on the original sync type, at the projection only, died only in one random run | Covered by the same witness's observation (the projection's row); not separately re-run |
| The Query retention witness did not separate "drop the fallback" from a full repair, and its prose blamed the fallback | Fixed: prose corrected; a peer-owner witness keeps the row, so the claim is an update and the stale owner comes from the claim's read of applied owners |
| A held removal of a hydrated row, then a claim, should clear the seed's metadata | Fixed: witness added; it fails on production and passes with the fallback dropped |
| Query witnesses read _state.syncedMetadata | Fixed where a DbClient exists: they read client.dehydrate(); the others say why they keep the internal read |
| Mutant: Query owners ignore accepted in-memory edges (rowToQueries supplement in getOwnersFromMetadata) | On production the new witness publishes a held owner beside a later claim of its row fails before the mutant matters. With the commit-order reader and the ?? fallback dropped, the mutant passes all 402 ownership tests. Probably equivalent there: each application that adds an edge also stages the owner set, and the reader then finds it. Open: a history with an in-memory edge but no staged owner write would separate them; none was found. If none exists, the repair may delete the supplement |
| Mutant: Query retires owner tracking before staging the cleanup | Equivalent by source inspection: no read occurs between the early retirement and the commit |
| Mutant: Query never retires owner tracking at cleanup | Killed by a sibling test (keeps a released row and fails loudly when its release commit fails), not by the retention witness |
Third review round#
Three fresh reviewers examined the D5 encoding, the Query and hydration tests, and a mutant campaign in a separate worktree.
| Finding | Disposition |
|---|---|
| D5 implied two answers for a seed that arrives after a partial source update | Fixed by the maintainer's choice "b" (decision 5); the session model places seeds by arrival, and seed order compares rows only under full updates |
| A later seed without metadata keeps the earlier seed's metadata; seeds always carried metadata | Fixed: bare seeds; bareLaterSeedClearsMetadata fails on 30175be48 |
| No fresh source insert over a seed-only row | Fixed: insertOverSeed action; insertOverSeedReplaces fails on 30175be48 |
| Seeds were never crossed with closed nesting | Fixed: nested-seeds lane |
| A late seed after a partial update was unwitnessed | Fixed: partialUpdateThenLateSeed fails on 30175be48 (the late seed's metadata is dropped) |
| A late seed erases Query's owner edges on a hydrated row Query claimed by a metadata-only write | Fixed: ignores a late hydration seed for a row whose metadata a claim has published fails on 30175be48 |
| An adapter insert over an initialData row merges its field; the hydration grid could not see it | Fixed: initialData rows carry a field and stay present in insert-first histories |
| The hydration grid had no metadata-only source write, and its reference cleared metadata on an equal re-insert of a source row | Fixed: the action is added and the clear is scoped to seed-only rows |
| Query tests matched the opaque owner-key encoding | Fixed: owner counts plus public-behaviour identity siblings (release and hydrated-release checks) |
| Stale counts, missing "hydration seed" glossary term, SSR omissions | Fixed |
| Mutants against a scratch D5 seed implementation: equal insert over a seed stays an update; replace only applied seeds; replace only pending seeds; metadata authority from row writes only; seed replacement in full mode only | Killed (by equalInsertOverSeed; seed-lane properties; hydration insert cells and seedUnderOpenInsert; seedUnderMetadataOnlyWrite; laterSeedReplacesSeed and partial hydration cells) |
| Mutant: Query owner supplement deleted | Survives with or without the fallback; probably equivalent, as recorded above |
| Mutant: Query fallback removed | Repairs exactly the fallback witnesses with no new failures |
| The persisted equal-insert witness is insensitive to core seed mutants | Accepted: it checks the wrapper's own clearing; core seeds are judged by the core lanes |
| A scratch D5 seed implementation still leaves seed lanes RED on histories with deletes, releases, and truncates between seeds | Open: diagnose during the production fix |
Fourth review round (prep-PR)#
A code review, a simplification pass, and 10× random campaigns examined the branch before its pull request. Items B to H were then taken through one plan of oracle changes before any repair: one grammar action for B and C, a late-abort probe for E, a scaling experiment for D, and a churn count for H.
| Finding | Disposition |
|---|---|
| A: a seed's metadata returns through the wrapper after an accepted equal re-insert (persisted-seeds, random seed -387838471) | Fixed: the wrapper hands inserts to core as whole-row updates (decision 9). Witness lets a later seed replace metadata that an accepted re-insert kept fails on 743a6ce14 |
| A sibling: an inner re-insert of an enclosing row commits ahead onto a seeded row; core classifies it insert and the kept value again claims the metadata | Fixed by the same change. Witness lets a later seed replace metadata that an inner re-insert kept; it alone kills the narrow fix that writes the kept value only when core would not keep it |
| A gap: no persisted lane crossed seeds with nesting or with the cached row | Fixed: persisted-nested-seeds, with reach seed under a kept re-insert (minimum 5) |
| Cached row versus a later DbClient seed: the seed could not replace the cached row or its metadata | Fixed (decision 9). Witness lets a later seed replace a cached row's metadata |
| An update without metadata after an enclosing insert dropped the insert's re-judgment, so an inner commit could not make it clear metadata (found by the 10× campaign) | Fixed. Witness rejudges an enclosing re-insert that a later update kept |
| The wrapper re-judged only a key's last insert without metadata against the value saved before it, so an earlier insert whose kind an inner commit changed was missed, in either direction (found by a second 10× campaign) | Fixed: each key keeps its chain of such inserts. Witnesses rejudges every enclosing re-insert of a key, not only the last and lets an enclosing clear become an equal re-insert after an inner commit |
| B: a hold hides a duplicate source insert (acceptSyncTransaction refreshes the projection only when a transaction moves) | Fixed. The grammar never wrote a different insert over a row the source wrote; the conflictingInsert action does, and the model expects core's duplicate-key error (decision 8 keeps duplicates in core). On 743a6ce14 plus decision 9, 26 core tests fail, every seeded lane among them, including hold invariance. Core's classifier now exempts only a row that only seeds supplied; a seed beneath an open insert keeps that insert's kind in the replay instead |
| C: a seed hides a duplicate insert in the same or an enclosing transaction | Fixed by the same change and caught by the same action (seed(1) begin upsert(1) conflictingInsert(1) was accepted) |
| D: the wrapper's staged-row fold is O(N²) per transaction | Fixed. 4000 inserts in one transaction took 48 ms against 0.8 ms on origin/main. Each staged transaction now keeps its row per key, so a write-time read folds one entry per transaction; 16000 inserts take 6 ms. A mutant that drops partial updates' merge base fails three witnesses and four persisted campaigns. No oracle measures work; the scaling run is the evidence |
| E: an aborted staged transaction leaves enclosing implicit metadata judged against it | Refuted for a store-held transaction: commit says that aborting after acceptance has no effect, and its inner transaction still applied. Confirmed and fixed for a transaction buffered behind a storage read, which an abort abandons before acceptance: when a deferred transaction leaves without reaching the Collection, the wrapper judges the enclosing inserts again. Witness judges enclosing inserts again when a buffered inner transaction is abandoned fails without it. A durable failure ends the run with a terminal error, outside this law |
| A storage read marked every row the Collection did not hold as a seed, including a row a peer's source wrote in this run and that this tab loads for a new demand | Fixed: the runtime records the keys that peer notices name as written in this run, and stops marking seeds after a notice or catch-up that needs a full reload, because it names no keys. Witnesses keeps a row another tab wrote this run when a later seed arrives (named keys, full reload) fail on e4b562448; a mutant that ignores a full reload fails the second |
| Query owner supplement (rowToQueries in getOwnersFromMetadata) | Removed. Each edge Query adds stages its owner set in the same sync transaction, and Query rolls edges back when that transaction fails, so the supplement only covered owner metadata that held transactions hid from reads. The commit-order reader makes it redundant; all 1,486 Query tests pass without it |
| F: Query's owner read lost its applied-metadata fallback for a sync without metadata | No action: core always supplies metadata, and no wrapper in the repository drops it |
| G: state internals are public for client.ts | Accepted design: the seed authority lookups moved to client.ts so that an app without DbClient ships 130 fewer gzip bytes |
| A later seed merged into an outer re-insert's row by an inner update that commits ahead (random seed 50521469; also fails in core on 743a6ce14) | Decision 10: the model now counts such a seed in the insert's kind. Witnesses lateSeedUnderMergedUpdate and heldLateSeedUnderMergedUpdate (core) and lets an inner update merged over a later seed change an enclosing re-insert (persisted). The held witness kills a core that keeps the insert's kind after the accepted inner write; without a hold the inner write applies and clears the seed mark, so that mutant is invisible there |
| H: appliedAdapterMetadataKeys grows with every deleted key | Fixed: the deleted-key and metadata-key sets became one map, appliedAdapterKeys, from each key whose metadata an applied adapter write fixed to whether that write deleted the row. After 5000 keys were inserted and deleted it holds 5000 entries instead of 10000 across two sets; the deleted keys remain, as D4 requires. The hydration authority grid gains delete,update cells, which a mutant that loses the returning key's authority fails. sourceWrittenKeys in the wrapper is unchanged |
Fifth review round (second high review)#
A high review of ad966ec12 raised ten items. The plan of oracle changes came first: per-key storage-read witnesses (peer metadata, a peer delete, a held seed, an equal re-insert), a read hold in the persisted grammar with the persisted-reads lane, a public-message witness, and scaling runs.
| Finding | Disposition |
|---|---|
| R1: a storage read marked as seeds for its whole transaction lets a later seed replace a peer's metadata | Fixed. The read stays one transaction; core keeps seed status only for the keys it lists (preserveHydrationSeedKeys may be a key set). Witness keeps a row another tab wrote this run… now checks metadata; it failed on 749079331 and passes with the origin/main wrapper |
| R1b: a DbClient seed held against a peer's write in this run | Fixed by decision 11; witness treats a row a peer wrote this run as a source row (replaces a held seed) |
| R2: validation deletes inside a seed transaction lost delete authority | Fixed by the same per-key rule; witness keeps a row a peer deleted absent when a later seed arrives |
| R3: a committed enclosing transaction stays judged against an abandoned buffered write | Refuted on the same path: the outer commit threw the hydration-cycle error, and after decision 12 the inner transaction either flushes or is dropped before the outer one commits |
| R4: writes during a storage read used older metadata rules | Fixed by decision 12: one write path. Witness clears an explicit metadata write with a later insert during a storage read and the persisted-reads lane (reach write during a storage read) fail on 749079331 and on origin/main |
| R5: prependHydrationRows did not shift partial-update indexes read by the staged-row walk | Resolved by removing the walk: each insert keeps its transaction's row from just before it |
| R6: core honoured originalSyncType from any public write | Fixed: the wrapper marks a whole-row update with the internal syncWriteNamesWholeRow symbol. Witness merges a public partial update whatever fields the message carries |
| R7: seed keys entered sourceKeys while staged | Refuted as unreachable: no caller classifies a seed-only row then |
| R8: two sets of keys written in this run | Fixed: the runtime keeps one record, fed by this tab's handoffs and peers' notices. Witness treats a row a peer wrote this run as a source row (keeps metadata on an equal re-insert) |
| R9: re-judgment was quadratic | Fixed by the same change as R5: 8000 implicit inserts re-judge in 2.5 ms (2000 took 18 ms). A mutant that judges against the transaction's whole row fails two witnesses and three campaigns |
| R10: readers copied the pending queue on every read | Fixed: they skip invalidated transactions in place |
| S1 (sibling): a commit threw when an on-demand read started during its transaction | Fixed by decision 12; the persisted-reads lane found it |
Sixth review round (third high review)#
A high review of ff80a31c3 raised nine items. The oracle changes came first: a peer-route witness grid (metadata-only notice, a missed notice, a peer row during an open insert) and stored rows for the persisted-reads lane's read hold, with reach storage read of a stored row.
| Finding | Disposition |
|---|---|
| H1: a peer's metadata-only notice did not count as a source write | Fixed: the runtime records row keys and metadata keys apart (D5 judges them separately). Witness treats a peer's write in this run as a source write (metadata-only notice over a held seed) failed on ff80a31c3 |
| H2: the gap-recovery fallback and term-fence reloads left the run's writes known | Fixed: both mark them unknown. Witness … (write whose notice was missed) failed on ff80a31c3 |
| H3: rows a peer's notice or a storage read landed ahead of an open wrapper insert did not judge it again | Fixed: every internal apply judges the open transactions' inserts again. Witness … (peer row during an open insert) failed on ff80a31c3 |
| H4: a held seed ignores the unknown-writes state that seed marking honours | Accepted design (decision 13): under unknown authorship, what this tab holds wins. The persisted oracle's seed-across-peer-notice cases with absent, older, and equal durable data witness it; a trial that let unknown rows replace held seeds failed eight of them |
| H5: the read lane's storage read returned no rows | Fixed in the oracle: the holding adapter keeps durable rows; the lane reaches storage read of a stored row and passes |
| H6: the storage read assumed begin opened the transaction it marks | Fixed: it marks seeds only when begin opened one |
| H7: the staged-row fold copied the queues on every read | Fixed: it iterates them in place |
| H8: re-judgment scanned every implicit insert on each inner commit | Fixed: it looks up the inner transaction's keys |
| H9: syncWriteNamesWholeRow was exported without @internal | Fixed |
Seventh review round (fourth high review)#
A high review of e4b81558b raised ten items. The oracle change came first: a reload-route grid in the persisted oracle, keeps previous-run rows seeds across a reload a peer's notice causes, which crosses the three reloads a named-key notice can cause (no DbClient seed held, so the reload truncates core; a held seed, so it rewrites rows in place; more keys than a targeted update allows) with a full-reload neighbour, and observes a later seed of a previous run's row, of the row the notice named, and of a key storage never held.
| Finding | Disposition |
|---|---|
| F1: a follower merges a targeted insert's row over its own and keeps a removed field | Refuted on the same path: a follower that shows the key reloads it from storage, and follows durable same-key order for targeted insert replacement (active and retired demand) passes |
| F2: an inner insert is judged against the enclosing open transaction's row, though it commits ahead onto another | Accepted design (decision 14). Production and the model agree (nested, nested-conflicts); a probe of the reported trace keeps metadata with no error. The mutant that re-judges accepted inserts, as 30175be48's replay did, fails both retention witnesses (fourth round) |
| F3: reloads a peer's notice causes rewrote previous-run rows as source writes, and the reload's truncate counted as a source truncate | Fixed (decisions 9, 11, and 15). A reload marks its transaction's seed keys: rows no source wrote, and under unknown writes rows storage left unchanged. Core keeps authority and seed status across that transaction's truncate, and the reload deletes each dropped row as a source write. The truncate stays, because it publishes each durable row's exact shape (three exact-shape witnesses need it). The grid failed three of four variants on 5013e5aba; a later seed of a never-stored key was ignored after a truncating reload. Decision 15 added a full-reload variant followed by a new demand's storage read and a row the peer deleted; both full-reload variants failed on 44ef0c2d0. Mutants: core treating the truncate as a source truncate (four variants fail), no unchanged-seed exemption under unknown writes (both full-reload variants), and no per-key delete of a dropped row (four variants). An earlier repair that validated rows in place lost the exact-shape publication |
| F4: every internal apply re-judged every open implicit insert | Fixed: the runtime passes the keys the internal apply's transaction wrote (every key after a truncate). Ten storage reads beside 4000 open inserts re-judged 40000 times, now none. A mutant that re-judges no keys fails … (peer row during an open insert); no generated lane catches it |
| F5: staging adds a storage read's seed keys to the source-key set that a rebuild excludes | Refuted as unobservable, as R7: a mutant that always rebuilds at acceptance changes no persisted session lane, persisted-oracle case, or core session lane; the wrapper's only classifier call skips keys no source wrote in this run |
| F6: metadata reads scan every pending transaction | Accepted cost of read-your-writes: 10000 reads take 0.7 ms with no held transaction and 4.2 ms with 100 |
| F7: the run's written-key sets grow, and the internal-apply listener outlives cleanup | No action: the sets keep deleted keys because D4 and decision 11 need them, as H; cleanup empties the arrays the listener reads, and the next run replaces it |
| F8: a redundant deleted-key check, and a stored acceptedSourceKeys only the rebuild reads | Fixed |
| F9: state internals are public for client.ts | Duplicate of round 4's G (accepted design); any follows client.ts's own AnyCollection convention |
| F10: seed keys are marked before the read's commit | Refuted: the read's begin, writes, and commit run synchronously, its commit takes no signal, a retired run's begin opens no transaction to mark (H6), and an accepted transaction always applies |
Eighth round (the peer dimension)#
After the fourth high review, the maintainer asked for the next oracle improvement before another review: a peer in the persisted session grammar. Every real finding since the fifth round had sat on a peer or reload route that only pinned grids covered.
- Law. A peer's write in this run is a source write, accepted when this tab applies its notice, after every accepted transaction and beneath the open ones (decision 11). A full-reload notice keeps a held DbClient seed (decision 13) and makes stored rows and metadata that differ source writes (decisions 15 and 18). Reloads keep metadata of a missing row (decision 16); a write over a seed stores the row this tab shows (decision 17).
- Grammar and model. The peer action sends one of four notices: a named row with its metadata, metadata alone, a delete, or a full reload. It is legal only when no store hold or read hold is active, so every accepted transaction of this tab is durable and the notice is the next in the stream. The model tells cached rows from DbClient seeds and keeps what a reload restores per row: storage's row and metadata, which each of this tab's commits sets to what this tab shows (decisions 16 to 18).
- Driver. A SingleProcessCoordinator that delivers the tx:committed shapes the Browser and Electron coordinators send; the holding adapter keeps rows and collection metadata.
- Lane and reach. persisted-peers (seeds, the cached row, nesting to depth 2, store holds, 150 fixed histories). A 150-history sample with the fixed seed reaches each named notice kind 44 to 69 times, a named write over a held seed 42, a full reload over a held seed 19, a peer write during a hold 46, and during an open insert 17; the declared minimum is 5. A declared state for a full reload that reveals a hidden peer write was dropped: a write of the row by this tab stores its row over the peer's (decision 17), so only a named notice reveals it, which named peer write over a held seed covers.
| Finding | Disposition |
|---|---|
| P1: a reload's held-seed set came from base, so it truncated over a seed accepted during a hold (hold seed(2) peer(1,row)) | Fixed: held seeds come from core's hydrated keys |
| P2: a named notice for a row only held transactions wrote merged the peer's whole row over this tab's held row (hold begin upsert(2) commit peer(2,row)) | Fixed: a row in base counts as ambiguous, and so does a held row that the notice would change, so the notice reloads. The first fix checked held rows only and regressed begin remove(1) hold commit peer(1,row); the lane found it at once. The second counted every held row and broke the real-host reload-class oracle's law (e), "a commit the tab already knows never makes it reread rows", on its Browser and Electron drivers: the notices it rerouted carried exactly the held row. A third version reloaded only when the notice's merge changed the held row; a peer's whole row that kept the held v and dropped its other fields then kept them. The grammar's row notice now has a variant that keeps this tab's v (peer(…,row,keeps)), and the replay a notice whose row keeps a held row's value still replaces it failed on that version. The final check reloads unless the notice carries exactly the held row, and the reload-class oracle passes on both drivers. Generated histories reach that premise too rarely to declare it (none in 300); the replay holds it |
| An in-place reload merged a stored row over a held seed (hold seed(2) seed(1,narrow) peer(2,row)) | Fixed: reloads write each stored row whole |
| A reload deleted again a row a held delete removed, dropping metadata written after it | Fixed: a dropped row is deleted only while core holds it, judged before the reload's truncate |
| F5 (seventh round, refuted as unobservable): staging counted a reload's preserved keys as source writes | Reachable after all: seed(2,bare) begin reinsert(1) hold peer(2,reload) judged the open re-insert equal. Fixed in core: staging skips preserved keys, as the rebuild does |
| L1, L2, and the later-seed metadata case | Decisions 16, 17, and 18 (B′) |
| A reload counted unchanged rows' writes as source writes, so metadata a seed governs lost that status | Fixed: a reload's write of an unchanged row keeps its status. A truncate in a storage read that keeps statuses keeps row metadata in core, and metadata.row.get reads through it; _syncedRowMetadataKeysOutsideBase left core |
| A named row notice did not mark its metadata as written in this run, so decision 18 kept a held seed's metadata over the peer's | Fixed: a peer's row comes with its metadata (decision 11) |
| Driver: the holding adapter dropped collection metadata, and cleared it on truncate | Fixed in the test adapter |
Evidence:
- RED. Each shrunk history above failed on the production before its fix; the survey of 2,000 generated histories before any fix found P1 in 22 and P2 in 9.
- GREEN. Four surveys of 1,000 generated histories (seeds 77, 91, 105, 133) find no mismatch; the fixed and random persisted-peers campaigns and the shrunk histories, kept as replays through the same model, pass.
- Mutants. P1 reverted fails both peer campaigns; P2 reverted fails the fixed one; B′ collapsed to B fails seven cases, two main full-reload cases among them; decision 11's metadata keys removed fails treats a row a peer wrote this run as a source row (replaces a held seed). Three mutants survive every campaign and fail only their replay: no seed base (decision 17), storage metadata always winning (decision 18), and a dropped-row delete without the held check. The campaigns reach their premises too rarely; the replays keep them. In core, staging that counts preserved keys (F5) fails its replay; a status-keeping truncate that clears row metadata fails nine cases, both peer campaigns among them; and a metadata reader that stops at such a truncate fails two replaces a hydrated seed and metadata with a peer's targeted … cases.
- Limits. The peer is the driver; no second Collection or real coordinator delivers its notices, so the claim stops at the controlled coordinator (ORC-014). The next receiving witness is a second production driver of this oracle with two coordinators over a real channel (issue #2272). Missed notices and notices over the targeted key limit are not generated; the persisted oracle's grids cover them. The adapter is in memory, and restart stays outside this oracle.
Ninth round (medium review)#
A medium review of 2156c9c57 raised ten items. Planned together, they asked for three oracle changes before any production edit: a peer lane under full row updates (M1), a reset notice (M4), and a Query storage-scan witness (M10).
- Law. A durable write stores the row this tab shows, so storage after settlement equals the model's storage, which a restart reads. Under full row updates an update replaces its row there too. A reset is a source truncate; eager it takes held seeds, on demand it keeps them with their objects' authority (decisions 19 and 20). A notice in a reset's term may predate the reset, so this tab reloads storage for it (the wrapper's reset fence).
- Grammar and model. The peer notice gains reset, which empties storage. The model's truncate may keep a set of keys, and a reset on demand keeps the DbClient seeds it holds. After a reset every notice reloads storage. New lanes: persisted-peers-full and persisted-peers-on-demand, the second with a demand for every row.
- Observation. The peer lanes now compare the holding adapter's stored rows with the model's after settlement. Before, storage was seen only when a reload read it back.
- Reach. The full-row lane declares accepted update drops a field; the on-demand lane declares reset over a held seed with the peer states. A reset over metadata of a missing row reached 1 in 150 histories, so a replay holds it instead of a declared state.
| Finding | Disposition |
|---|---|
| M1: decision 17's seed merge ignored rowUpdateMode: 'full' | Confirmed and wider: the wrapper stored every sync update as a merge, so under full rows a reload or restart brought back fields an update dropped, with or without a seed (main too). Fixed: only a partial update merges in storage; every other write replaces its row |
| M2: seed marks added at a read's begin | Refuted, as F10 |
| M3: seed metadata applies over a rejected row | Accepted: row and metadata are separate objects (D5) |
| M4: a reset beside a held seed kept orphan metadata | Confirmed and wider: the reset also kept rows a hold kept from base and skipped the demand replay its truncate drives; a reload deleted only base rows that storage lacked. Fixed: a reset is a source truncate that keeps held seeds (decisions 19 and 20), and a reload deletes every accepted row that storage lacks |
| M5: a status-keeping truncate keeps flags of dropped keys | Refuted: a dropped key is outside the read's preserved keys, so its delete is a source write and clears its flags (the grid's gone row) |
| M6: an accepted inner equal insert keeps its kind | Accepted (decision 14) |
| M7, M8 | Duplicates of F9 and F7, accepted earlier |
| M9: the commit wrapper reads the newest pending transaction on every commit | Harmless: withInternalApply resets the key set before each use |
| M10: Query's storage-scan owners omit accepted edges | Open; see "Open" |
| Lane: a reset kept a held seed but cleared the authority a peer's metadata write had fixed over it, so a later seed's metadata replaced the peer's | Fixed: the reset's truncate keeps the authority entries of kept keys |
| Survey: a source row write after an on-demand reset closed the kept seed's metadata to a later seed | Fixed: a kept key stays exempt for its metadata; a row write fixes row authority through the applied row |
| Survey: after a full-reload notice, a named notice that the wrapper turns into a reload erased a seed's metadata of another row (peer(1,reload) seed(2) begin upsert(2) commit peer(1,row,keeps), also on 2156c9c57) | Fixed: a notice that names no keys makes writes unknown per key, not for the rest of the run. A key is known again once this tab's source writes it, and a storage read records each row it finds changed as this run's source write. This tab's own truncate still makes every later write unknown. Ending the unknown state with the reload instead failed two persisted-oracle witnesses: an on-demand reload with no demand reads no rows, so a row it never read must still beat a later seed. Marking keys known before the read wrote them failed both oracles widely. Mutants: not recording changed rows fails the fixed campaigns of all three peer lanes; no knowledge from this tab's writes fails the family's replay; knowledge kept across a later full-reload notice fails 16 cases. Knowledge from a read's rows too survived every case and was removed |
Evidence:
- RED. On 2156c9c57's production, ten cases fail: the five new replays and the fixed and random campaigns of both new lanes. A replay of the kept-authority case failed on the first M4 repair.
- GREEN. Every persisted session lane and replay passes at the configured runs. Two surveys at four times the runs found two families, one on the reset path and one that 2156c9c57 already had; both are fixed and pinned, and two more surveys at four times the runs pass.
- Mutants. Killed: an update over a seed merging under full rows; no seed base for partial updates (now by both partial campaigns, through the storage check); every update replacing; a reset that keeps no seeds; no rewrite of a kept seed's row; reload deletes from base only; authority that ignores kept keys; a truncate that clears kept metadata. For the scoped unknown writes, the committed sticky flag fails the family's replay, and not recording changed rows fails the fixed campaigns of all three peer lanes. Survived: a session reader that stops at the reset's truncate for kept keys. A truncating transaction applies at once, so no read sees it pending; the reader change was removed.
- Limits. The reset's term fence is exercised only through the driver's single-term peer. The full-row and on-demand lanes cover the peer grammar, not the other lanes' holds and reads.
Tenth round (high review)#
A high review of 18f7df562 raised ten items (H1 to H10).
- Law. A reset's own reload reads metadata through its open truncate only for kept seeds. What storage holds after a reset is this run's source writes. A transaction the wrapper abandons before handoff wrote nothing, so a seed beneath it holds (decision 21); a seed that arrives after it stays judged against it (decision 22).
- Grammar and model. A reset may rewrite its rows: the resetting tab writes storage's rows back, and the reload applies them after its truncate. A new abandon action aborts the signal of the newest outermost transaction committed during a read hold; only the read's end may follow, which abandons it. A transaction begun over a buffered one reads it and becomes a dependent successor, which the abort-graph oracle owns, so abandon needs none. New lane persisted-reads-seeds crosses read holds with seeds and the cached row, loaded by a demand for every row.
- Reach. reset that rewrites its rows and abort after commit are declared. Abandoning the writer of a held seed's row stays below five in 150 histories; a replay pins it.
| Finding | Disposition |
|---|---|
| H1: the session reader read through a reset's open truncate, so the reset skipped writing stored metadata equal to what it was about to delete | Confirmed. RED: the rewriting reset's replay and the on-demand campaigns. Fixed: the reader stops at a source truncate for keys it does not keep. Round 9 had removed this as unreachable; the wrapper reads inside its own open reset |
| Sibling: an equal re-insert of a row a reset wrote back cleared its metadata | Fixed: a reset records the rows it restores as this run's source writes |
| Sibling (model): the model kept decision 16's metadata of a missing row through a rewriting reset | Fixed in the model: only rows are durable |
| H2: run writes were recorded before the handoff and kept when it abandoned the transaction | Confirmed. RED: the abandon replay. Fixed: only a transaction handed to core with a live signal is recorded |
| H3: truncate ignores an invalid open transaction | Accepted design (decision 7); a mutant that revives it fails the nested and conflict campaigns |
| H4: a read's seed marks and run-written rows stay if its transaction is discarded | First half refuted earlier (F10). Second half true but harmless: under unknown writes a changed stored row is this run's source write whether or not the read applies (decision 15) |
| H5: the targeted-invalidation seed checks are always false | Confirmed by source: the notice's keys are recorded first. Removed |
| H6: the hydration binding's cross-cycle throw and its assert are unreachable | Confirmed by source. Removed with their try; the assert at the handoff stays |
| H7 | Duplicate of G (accepted design) |
| H8 | Duplicate of F7; the newer known-key set is cleared at each full-reload notice |
| H9: appliedAdapterKeys keeps an entry for nearly every synced key while a DbClient tracks authority | Accepted: per-object seed authority needs it (D4, D5); the map exists only under DbClient tracking |
| H10: the session reader walks every pending transaction | Observation, no performance law: pending transactions accumulate only while an optimistic transaction holds sync |
| Survey: a seed after a buffered write that is then abandoned | Decision 22; the model records each seed's losses before abandonment |
Evidence:
- RED. On 18f7df562: H1's replay and the on-demand campaigns; the re-insert sibling's replay; H2's replay.
- GREEN. Every persisted session lane and replay; three searches of persisted-reads-seeds at four times the runs.
- Limits. The abandon action covers one buffered transaction with no successor; dependent suffixes belong to the abort-graph oracle.
- Files. Under oracle/no-unit-test (#2278), the session model module declares its check, model, and grammar. Its fixed boundary tests moved to packages/db/tests/sync-metadata-session.test.ts, and the persisted fixed witnesses to packages/db-sqlite-persistence-core/tests/persisted-sync-metadata-session.test.ts.
Open#
Held-cleanup retention witnesses race on CI. does not publish a retention-cleaned owner through a claim made during the cleanup hold and hydrated clients drop a held-cleanup row once its peer and claimant release it (Query ownership lifecycle) sometimes see no expiry transaction staged within 5 seconds of the unload while an optimistic blocker holds sync. They failed on CI at 1c7624710 and 9ec84d81c and passed on the rerun of 9ec84d81c, at c065ab5ec, and at bc8f5f3d2; locally they pass five times in five at load average 43. The witnesses without a hold pass in under 120 ms. Refuted: a Node timer firing before the entry's wall-clock deadline (a timer set after a 50 ms busy task fired 12 ms later), and a wait on a held receipt (no step of the expiry awaits one). The witnesses now trace each timer Query DB sets during the test (where it was set, whether it fired, what cleared it) and each Query fetch, and a failed retention wait prints that trace. With it, the witnesses passed on CI at 543d29066 and in four local runs of CI's collections command. Deferred by the maintainer on 2026-10-27: the pull request merges with the race open ("it'll eventually fail at which point we can fix it"). Resume when a failure reports its trace; the trace names the step to fix, in production if a valid expiry was cancelled, or in the witness if only its timing raced.
Query storage-scan owners (M10). getOwnersFromMetadata no longer adds this tab's accepted edges, which matters only where it reads a storage scan: loadPersistedBaselineForQuery for a retained Query whose known rows are not all in the Collection. Three witnesses (a held scan beside another Query's claim; a retained Query with a stored row, and with a row moved out of its predicate) each took the in-memory baseline, which includes the claim. The scan and every storage hydration hold the apply lock, so a claim during a held scan waits. Not excluded: a claim committed during a storage read, which the wrapper buffers (decision 12), with a retained Query's scan before the buffer flushes. That history is the missing witness; no production change without its RED.
Nothing else in this bug class is known to be open. The limits in the evidence block still apply.
Oracle guide requirements#
| Requirement | Outcome |
|---|---|
| ORC-001 | Met: decisions and sources above; limits in the module's opening prose. |
| ORC-002 | Met: the model folds an event log; production scans queued write sets. |
| ORC-003 | Met: contract, model, grammar, driver contract, and comparison are separate sections with prose. |
| ORC-004 | Met: witnesses are reconstructed by the grammar; the ablation test shows each axis is needed for its regime; range probes added prefix-boundary keys (new values only) and a seed field (a missing merge rule, now modelled); exclusions (remove or reinsert of an absent row) are stated. |
| ORC-005 | Met: real sync API, DbClient.hydrate, and the persisted wrapper; observations at each checkpoint. |
| ORC-006 | Met: production on 30175be48 and the temporary reader mutants. |
| ORC-007 | Met: fixed and random campaigns per lane with replay properties registered in oracle-config.ts. |
| ORC-008 | Met: frames, accepted queue, seeds, and hold each change an observation (see the witnesses). |
| ORC-009 | Met: frames are sync transactions, each independent (decision 6); the model's serialization order is the order a read and an application use. |
| ORC-010 | Met: the runner preserves the mismatch and aggregates a cleanup failure. |
| ORC-011 | Met: three properties compare production with itself, without the model's reads: hold invariance (holds removed), key independence (row 2's actions removed), and seed order (seeds performed first, decisions D4 and D5). A shared fault in the model's reads cannot pass them. |
| ORC-012 | This record. |
| ORC-013 | Met for hold kind (store-hold neighbour passes, core hold fails on 30175be48) and nesting (inner commits during a hold fail on 30175be48; outer aborts and unheld inner truncates pass there). |
| ORC-014 | Met: the persisted lanes receive the flat, nested, and seed laws through the wrapper. Restart and resume belong to other owners. |