Skip to content

Durable outbox (ADR-006 §1) + pull-apply sibling replay (DAN-777) - #25

Merged
Danny-Devs merged 5 commits into
mainfrom
tackle/DAN-777-coordinator-fast-follows
Aug 14, 2026
Merged

Durable outbox (ADR-006 §1) + pull-apply sibling replay (DAN-777)#25
Danny-Devs merged 5 commits into
mainfrom
tackle/DAN-777-coordinator-fast-follows

Conversation

@Danny-Devs

@Danny-Devs Danny-Devs commented Aug 3, 2026

Copy link
Copy Markdown
Owner

What

The two deliberate deferrals from DAN-776's landing review, closed (DAN-777).

Finding A — the durable outbox (ADR-006 §1), Option 1, the real fix. EnableSyncOptions.outboxEngine?: StorageEngine takes a separate engine instance (§1's own-file/store requirement — engines are parameterizable by dbName, so idbEngine({ dbName: "cdb_outbox" }) is a genuinely separate store). Entries and a per-client seq watermark persist at commit time and restore on boot: pending pushes survive reloads relayed with identical mutationId/seq (never re-authored), and seq never regresses — the watermark is its own row, never derived from surviving entries (confirmed rows are deleted; re-issued seqs are silently ignored server-side, which is the exact post-reload write-loss hazard). Writes racing the async boot buffer with everything captured at commit time (D4) except seq, which waits for the restored watermark. Persistence faults degrade to in-memory with a warning; absent engine = old behavior, loudly documented on clientId and pinned by a test.

Finding B — pull-apply sibling replay, completing the reject/transform trio: a pull-applied remote change now replays still-pending same-key writers inside its own sync-pull channel (no echo, no new outbox entries) — pending optimistic edits no longer visibly vanish until the push echo returns. Related: ack/transform version stamps route through a guarded stamp that never regresses a newer pull's stamp (D17 overlap), and a stale transform's data is gated — checked against both the temp id's and the target id's stamps.

Review history — three adversarial rounds, all findings fixed in-branch

  • Tackle round (7 findings): the serious one was the residual half of the stamp guard — a stale transform's data still applied, and the guarded stamp made the divergence permanent (re-delivery classifies "same"); plus engine-lifecycle leaks, delta-mark ghost entries (per-client confirmedHighWater now retires late-restored/adopted entries), boot-window gaps in replay/remap, NaN-poisoning corrupt rows, hidden buffered writes.
  • Landing gauntlet (3 blockers + 1 advisory): the fixes' own siblings — the stale gate consulted only the temp-id key so a remapped stale transform still diverged permanently; the loadAll-fault fix leaked the open handle (close-ownership now routes through one helper with split open/writable flags); stop-during-loadAll double-closed (StorageEngine forbids calls after close); a "concurrent" transform's stamp didn't follow its applied data, so the server's echo re-applied forever.
  • Two pre-existing tests pinned the old mid-flight vanishing behavior and were updated to pin the replay; their final divergence-prevention assertions are unchanged.

Gate evidence

  • Packet verification command: VERIFIED (exit-chain unlaundered).
  • CI=true pnpm -r test: 647 passed, 2 skipped (pre-existing sqlite pair) + 29 mcp. Suite 630 → 647.
  • typecheck / build / lint: green.

Watched to fail — 17 mechanisms

Initial six (pull-apply replay, both stamp guards, watermark restore, entry persistence, pre-boot buffer), tackle-round seven, gauntlet-round four — every one mutation → named test red → revert/fix. One of my own new tests was caught passing vacuously (a poke racing the in-flight boot pull was dropped) and re-timed until it discriminated.

Known limitation, stated: retirement is a fire-and-forget delete; a crash between a once-only delta-style mark and the delete can leave a ghost row on next boot. Wire-protocol-v1 mandates marks on every response, so the shipped stack is unaffected.

Linear: DAN-777

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added durable offline change recovery, preserving pending edits across reloads and restarts.
    • Improved synchronization during startup, including buffered local changes and restored sequence state.
    • Added graceful fallback when persistent storage is unavailable or contains corrupted data.
    • Improved handling of confirmations, rejected changes, identifier remapping, and pending-change counts.
  • Bug Fixes

    • Prevented stale server updates from overwriting newer local changes.
    • Improved replay of pending edits after remote updates and recovery scenarios.
  • Documentation

    • Updated the changelog and synchronization architecture documentation.

Danny-Devs and others added 4 commits August 2, 2026 18:42
… DAN-776's two deliberate deferrals (DAN-777)

Finding A: EnableSyncOptions.outboxEngine takes a SEPARATE StorageEngine
instance; entries and a per-client seq watermark persist at commit time and
restore on boot. Pending pushes survive reloads relayed with identical
mutationId/seq; the watermark is its own row (confirmed entries are deleted,
and deriving seq from survivors would re-issue seqs the server ignores —
the silent post-reload write-loss hazard). Pre-boot writes buffer with all
commit-time capture except seq, which waits for the restored watermark.
Absent engine: old behavior, now loudly documented on clientId and pinned.

Finding B: a pull-applied remote change now replays still-pending same-key
writers inside its own sync-pull channel, completing the reject/transform
trio — pending optimistic edits no longer vanish until the push echo. Two
tests that pinned the vanishing behavior mid-flight were updated to pin the
replay; their final divergence-prevention assertions are unchanged. Related:
ack/transform version stamps are now guarded and never regress a newer
pull's stamp (D17 overlap).

Six mechanisms watched to fail. Suite 630 -> 637.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A buffered write is an unconfirmed local write; reporting 0 during the
boot-load window hides exactly the writes at their most volatile.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ansform data gate chief among them

B1: a transform whose version compares older than the pull-stamped one now
skips its data apply and sibling replay (the guarded stamp alone made the
divergence PERMANENT — re-delivery of the newer state classifies 'same');
the id remap still applies, since identity is not versioned. B2: stop()
during a slow engine open() now closes the engine instead of leaking a
handle that can hold an OPFS lock and degrade the next instance to
in-memory. A1: a per-client confirmed high-water mark retires entries
restored or adopted AFTER a delta-style mark already covered them (ghost
re-push forever). A2: a loadAll fault degrades to pure in-memory instead of
leaving the engine writable over the previous session's rows. A3/A4:
sibling replay and id remaps now reach writes still buffered behind the
boot window. A5: corrupt persisted rows are skipped, not folded into
seqCounter as NaN.

All seven watched red first. Suite 637 -> 643.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…se-ownership helper, stamp-follows-data

G1: the stale-transform gate now checks BOTH keys — a remapped stale
transform's temp id is usually unstamped while the newer state it must not
overwrite is stamped under the target id (the exact B1 divergence, one key
over). G2/G3: close-ownership routes through one helper with split
open/writable flags — a loadAll fault degrades writes to in-memory while
stop() still releases the handle, and a stop firing mid-loadAll no longer
double-closes (StorageEngine forbids calls after close). G4: a 'concurrent'
transform's stamp now follows its applied data, or the server's echo of
that very version re-applies forever under a custom comparator. Also
deleted A1's dead scaffolding block.

All four watched red first. Suite 643 -> 647.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@Danny-Devs, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 49 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 4e51c554-d4ba-4898-9c46-5aaf474123d5

📥 Commits

Reviewing files that changed from the base of the PR and between 1722f19 and f796485.

📒 Files selected for processing (3)
  • CHANGELOG.md
  • src/coordinator-conformance.spec.ts
  • src/coordinator.ts
📝 Walkthrough

Walkthrough

The coordinator adds optional durable outbox storage with restoration, buffering, sequence recovery, fault handling, and cleanup. It also replays pending mutations after pulls, protects newer state from stale verdicts, updates remapped entries, and expands conformance coverage.

Changes

Durable outbox and lifecycle

Layer / File(s) Summary
Outbox persistence and lifecycle
src/coordinator.ts, src/coordinator-conformance.spec.ts, CHANGELOG.md, docs/adr/006-sync-adapter-interface.md
EnableSyncOptions accepts an optional StorageEngine. The coordinator persists and restores outbox entries, recovers sequence watermarks, buffers boot-time mutations, skips corrupt or confirmed rows, degrades on storage failures, reports buffered pending counts, and closes the engine during shutdown. Tests and documentation cover these behaviors.

Sync replay and verdict handling

Layer / File(s) Summary
Replay, verdict, and remap handling
src/coordinator.ts, src/coordinator-conformance.spec.ts
Pull-applied changes replay pending same-key mutations. ID remaps update buffered and persisted entries. Stale transforms do not overwrite newer data. Confirmation watermarks retire entries, and rejected entries are removed from durable storage. Tests cover replay, stale verdicts, remaps, and custom version comparison.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant LocalMutation
  participant Coordinator
  participant StorageEngine
  participant SyncServer

  Coordinator->>StorageEngine: open and load durable outbox
  LocalMutation->>Coordinator: submit mutation during boot
  Coordinator->>Coordinator: buffer mutation
  StorageEngine-->>Coordinator: return rows and sequence watermark
  Coordinator->>StorageEngine: persist buffered mutation
  SyncServer->>Coordinator: apply pull change
  Coordinator->>Coordinator: replay pending same-key mutations
  SyncServer->>Coordinator: send verdict
  Coordinator->>StorageEngine: retire confirmed or rejected entry
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies both primary changes: durable outbox support and pull-apply sibling replay.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch tackle/DAN-777-coordinator-fast-follows

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🧹 Nitpick comments (2)
src/coordinator-conformance.spec.ts (1)

1033-1036: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

The writeBatch double resolves immediately, so it cannot expose write-ordering faults.

writeBatch applies puts and deletes with no delay and no queueing. Every call resolves in the same microtask in call order. The durable-outbox tests therefore cannot reach the case where a fire-and-forget delete resolves before an earlier fire-and-forget put for the same key. See the related comment on src/coordinator.ts lines 296-313.

Add an optional writeDelayMs (or a per-call jitter) so at least one test drives concurrent, out-of-order batch completion.

♻️ Optional delay hook on the double
-function fakeOutboxEngine(
-  rows: Map<string, unknown> = new Map(),
-  opts: { openDelayMs?: number; failLoadAll?: boolean; loadAllDelayMs?: number } = {},
-) {
+function fakeOutboxEngine(
+  rows: Map<string, unknown> = new Map(),
+  opts: {
+    openDelayMs?: number;
+    failLoadAll?: boolean;
+    loadAllDelayMs?: number;
+    /** Per-call latency, so concurrent fire-and-forget batches can complete
+     *  out of submission order — the real-engine hazard. */
+    writeDelayMs?: (callIndex: number) => number;
+  } = {},
+) {
   let closes = 0;
+  let writeCalls = 0;
     async writeBatch(puts, deletes) {
+      const delay = opts.writeDelayMs?.(writeCalls++);
+      if (delay) await new Promise((r) => setTimeout(r, delay));
       for (const p of puts) rows.set(p.key, p.value);
       for (const d of deletes) rows.delete(d);
     },
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/coordinator-conformance.spec.ts` around lines 1033 - 1036, Update the
writeBatch test double to support an optional writeDelayMs (or equivalent
per-call jitter), delaying each batch before applying its puts and deletes and
resolving. Configure at least one durable-outbox test to use the delay so
concurrent fire-and-forget batches can complete out of order, while preserving
immediate behavior when no delay is configured.

Source: Path instructions

CHANGELOG.md (1)

40-73: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Rewrite the review-process narrative from the reader's perspective.

Lines 40-42, 44-45, and 60-61 describe the internal review process, not the shipped behavior. Examples: "Six mechanisms watched to fail (mutation → named test red → revert)", "Suite 630 → 637", "A fresh-context review round then found 7 more, all fixed here with red-first tests (suite → 643)", and "The landing gauntlet then found the fixes' own siblings — 3 blockers + 1 advisory".

The path instruction for CHANGELOG.md requires entries written from the reader's perspective. A consumer of this package cannot act on review-round counts, blocker counts, or suite totals. The suite totals also rot on the next test added.

Keep the behavioral statements. Remove the round framing and the counts. For example, state that a stale transform no longer applies its data and no longer replays siblings, and that the id remap still applies.

The "Known limitation, by design" text at lines 69-73 is reader-facing and should stay.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@CHANGELOG.md` around lines 40 - 73, Rewrite the affected CHANGELOG narrative
to describe only shipped behavioral changes from the reader’s perspective:
remove review-process framing, test-suite totals, round labels, and
blocker/advisory counts. Preserve the concrete behavior of stale-transform
handling, lifecycle and persistence fixes, confirmation retirement, buffered
writes, corrupt-row handling, and related fixes; retain the reader-facing “Known
limitation, by design” section unchanged.

Source: Path instructions

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/adr/006-sync-adapter-interface.md`:
- Line 4: Update the DAN-777 achievement clause in the ADR implementation status
to explicitly identify the shipped opt-in durable outbox API as outboxEngine on
EnableSyncOptions, together with stable-identity reload semantics. Remove
wording that implies the requirement remains unsatisfied, while preserving the
distinction that enableSync() without outboxEngine retains in-memory behavior.

In `@src/coordinator-conformance.spec.ts`:
- Around line 1166-1222: Update both reload tests to create a separate outbox
engine for each session while preserving the shared rows map: use one engine for
the first boot and a newly constructed engine for the second boot in the tests
beginning “seq resumes…” and “a pending (unconfirmed)…”. Ensure the fake
StorageEngine double rejects calls after close by asserting !closed in each
method, so reuse of a closed engine fails loudly.

In `@src/coordinator.ts`:
- Around line 383-403: Ensure the loadAll error path in the coordinator
initialization flow does not materialize and push entries with seqCounter still
at zero: recover the watermark via loadMany using OUTBOX_META_KEY before falling
back, or mark the session unable to push and expose that state through
getRetryState(). Add a red-first test covering a seeded watermark and asserting
the first post-fault push sequence.
- Around line 296-313: The fire-and-forget outbox writes in persistEntry and
retireEntry can complete out of order. In src/coordinator.ts lines 296-313,
route both writeBatch calls through a single serialized tail promise that
preserves submission order. In src/coordinator-conformance.spec.ts lines
1033-1036, add a per-call delay hook to fakeOutboxEngine.writeBatch and a test
exercising an out-of-order put/delete pair for the same entry key, verifying the
retired row is not recreated.
- Around line 100-127: The committed API report is missing the exported
EnableSyncOptions.outboxEngine option. Update etc/colada-db.api.md to include
outboxEngine?: StorageEngine in EnableSyncOptions, then rerun the API and
package-surface checks to verify the report is synchronized.
- Around line 593-596: Update the ack handling around stampVersionIfNewer() and
applyRemoteChange() so an ack version classified as “same” or “concurrent” is
stamped consistently, preventing the server echo from being re-applied; add a
focused conformance test parallel to the concurrent transform G4 case, or revise
the ack policy if acknowledgements should not stamp versions.

---

Nitpick comments:
In `@CHANGELOG.md`:
- Around line 40-73: Rewrite the affected CHANGELOG narrative to describe only
shipped behavioral changes from the reader’s perspective: remove review-process
framing, test-suite totals, round labels, and blocker/advisory counts. Preserve
the concrete behavior of stale-transform handling, lifecycle and persistence
fixes, confirmation retirement, buffered writes, corrupt-row handling, and
related fixes; retain the reader-facing “Known limitation, by design” section
unchanged.

In `@src/coordinator-conformance.spec.ts`:
- Around line 1033-1036: Update the writeBatch test double to support an
optional writeDelayMs (or equivalent per-call jitter), delaying each batch
before applying its puts and deletes and resolving. Configure at least one
durable-outbox test to use the delay so concurrent fire-and-forget batches can
complete out of order, while preserving immediate behavior when no delay is
configured.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 25224a7f-8c34-4118-bc0e-60c4b844cce3

📥 Commits

Reviewing files that changed from the base of the PR and between dbd9656 and 1722f19.

📒 Files selected for processing (4)
  • CHANGELOG.md
  • docs/adr/006-sync-adapter-interface.md
  • src/coordinator-conformance.spec.ts
  • src/coordinator.ts

Comment thread docs/adr/006-sync-adapter-interface.md
Comment thread src/coordinator-conformance.spec.ts
Comment thread src/coordinator.ts
Comment thread src/coordinator.ts
Comment thread src/coordinator.ts
Comment thread src/coordinator.ts
…e writes, comparator policy pin

R1: a loadAll fault now suspends pushes (RetryState.suspendedForOutboxFault)
instead of materializing buffered writes at virgin seqs a previous session
already burned — seq <= lastSeen is silently ignored server-side, so the
fault path had resurrected finding A's silent write loss. R2: all engine
writes ride one serialized tail; writeBatch guarantees per-batch atomicity,
not inter-batch ordering, so a fast retire delete could lose to its own
entry's slower put and resurrect a ghost row. R3: pinned the
ack-doesn't-stamp policy under a same/concurrent comparator (one idempotent
echo re-apply, then converged). Reload tests now use a fresh engine per
session per the no-calls-after-close contract.

R1/R2 watched red first. Suite 647 -> 650.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Danny-Devs

Copy link
Copy Markdown
Owner Author

@coderabbitai resolve

@coderabbitai

coderabbitai Bot commented Aug 14, 2026

Copy link
Copy Markdown
✅ Action performed

Comments resolved. Approval is disabled; enable reviews.request_changes_workflow to allow explicit top-level @coderabbitai resolve or @coderabbitai approve commands.

@Danny-Devs
Danny-Devs merged commit 333eb36 into main Aug 14, 2026
13 checks passed
@Danny-Devs
Danny-Devs deleted the tackle/DAN-777-coordinator-fast-follows branch August 14, 2026 07:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant