Base revision: 931e8346f (main). Grid revision: 4c338ddbd. First repair revision: 79c7786b4 (deferral). Split-transition revision: 9bbeb1063.
Each change message must be valid for a consumer that has applied every earlier message: an insert names an absent key, and an update or delete names a present one. Authority: the change-message contract in packages/db/tests/change-event-history-oracle.test.ts and issue #1901.
A truncate commit that makes the Collection ready runs ready callbacks (onFirstReady and status:change listeners) during the commit, and a callback may write. The truncate's messages and the callback's messages together must be valid for every subscriber, with or without initial state and with or without a filter. Each subscriber must end at the Collection's rows. Subscribers receive the truncate's messages only after the Collection is ready.
State mutation round 3 found that a mutant which ran markReady before the truncate reapply survived the suite. The ready-callback witness covered only an edit of a replaced key, through a subscriber with initial state, whose sent-key filter hides a repeated message.
The bug on main: the truncate built its batch from the replaced rows, then called markReady before it published. A callback's messages describe the replaced rows, but they reached subscribers before the batch that moves subscribers to those rows. Reachable failures:
The first repair (7331f25aa) dropped prefix deletes for keys a callback deleted. Code review and CodeRabbit showed the other three failures. That repair also made the stale re-applied row silent, because the repeated delete no longer exposed it.
| Item | Finding | Outcome |
|---|---|---|
| 1 | The deferral enriched the batch at publication, so a source row could go out as local. | Fixed by the split. 48 grid cases fail with the deferral. |
| 2 | The empty ready event skipped the deferral, so a live query became ready with pre-truncate rows. | Fixed by the split. All 144 cases fail with the deferral. |
| 3 | A truncate committed inside the sync function, with a throwing onFirstReady, throws from commit() and moves the Collection to error. ops.markReady() defers the same error. | Confirmed on main too. Open, for a separate fix. |
| 4 | A subscriber that a ready callback creates received the whole batch. | Fixed by the split. 120 cases fail with the deferral. |
| 5 | The deferral delivered the batch and the callbacks' messages as one uncomposed batch. | Fixed by the split: they are separate publications. |
| 6 | The deferral is a truncate-only special case. Split the ready transition. | Adopted. |
| 7 | One failure-fidelity case was vacuous. | Fixed: an already-ready Collection runs no ready callbacks, so the case is removed. |
| 8 | The grid did not check virtual props, live queries, or callback subscribers. | Fixed: see the witnesses above. |
| 9 | The two guards for the deferral encoded one condition. | Gone with the deferral. |
| 10 | A cleanup inside a ready listener dropped the deferred batch. | Fixed by the split: the batch emits before listeners run. |
CodeRabbit found that the deferral let a subscriber error escape from the publication before the commit resolved its applied receipts. A probe showed the same hang on main, through markReady's empty ready event. When a truncate made the Collection ready, a throwing subscriber or onFirstReady callback left a held receipt pending forever.
The emit and the ready transition now run through one capture that keeps the first error. The commit reports that error after its receipts settle, as it does when the Collection is already ready. A witness holds a sync transaction behind a persisting request, then truncates while a subscriber or a ready callback throws. A third case throws from a subscriber on an already-ready Collection. The two not-yet-ready cases fail on main and on the uncaptured deferral.
A truncate committed inside the sync function itself, whose ready callback threw, threw from commit() and moved the Collection to error. The follow-up record defers that error the way ops.markReady() does.
The grid covers one optimistic callback write per truncate. These histories remain outside it:
This fix merged with the settlement-drop change (2026-10-03 review), where readiness counts accepted rows. A ready truncate batch still publishes before ready effects run: the reentrancy oracle passes on the merged revision, and S6 still fails it.