Skip to content

fix(M12): sync safety + cache coherence (T1 + T2)#50

Merged
flyingrobots merged 5 commits intomainfrom
fix/m12-t1-sync-safety
Feb 27, 2026
Merged

fix(M12): sync safety + cache coherence (T1 + T2)#50
flyingrobots merged 5 commits intomainfrom
fix/m12-t1-sync-safety

Conversation

@flyingrobots
Copy link
Member

@flyingrobots flyingrobots commented Feb 27, 2026

Summary

  • M12.T1 — Sync Safety (B105, B106, B107): Route applySyncResponse through _setMaterializedState() instead of raw cache assignment. Validate ops against isKnownOp() allowlist (fail closed). Add isAncestor() pre-check in processSyncRequest for early divergence detection. Surface skippedWriters in syncWith return.
  • M12.T2 — Cache Coherence (B108): Fix join() to install merged state as canonical (_stateDirty = false, adjacency built synchronously) instead of marking dirty which caused _ensureFreshState() to discard the merge result. Clear _cachedViewHash in all dirty paths. Widen stateHash type to string|null for deferred hash computation.

Test plan

  • 4378 unit tests pass (244 files)
  • WarpGraph.noCoordination.test.js — 7/7 pass (non-negotiable gate)
  • WarpGraph.coverageGaps.test.js — 38 tests including 6 new B108 regression tests
  • SyncController.test.js — 39 tests including new sync-apply coherence tests
  • SyncProtocol.test.js — 32 tests including divergence + op validation tests
  • ESLint clean
  • TypeScript strict mode clean (0 errors)
  • IRONCLAD pre-push gate passed

Summary by CodeRabbit

  • Bug Fixes

    • Prevented stale-state and incorrect query results after sync by installing canonical materialized state and clearing stale caches.
    • Unknown sync operations now fail fast instead of being ignored.
    • Faster divergence detection to skip incompatible writers earlier.
    • Cleared cached view hashes on dirty paths and GC boundary changes.
  • Types

    • Sync responses now include skipped-writer details; some state-hash fields may be null.
  • Tests

    • Added coverage for merge, sync, dirty-path, and unknown-op scenarios.
  • Documentation

    • Roadmap updated with re-prioritized milestone and detailed audit plan.

… op allowlist, surface skippedWriters

B105: Replace direct _cachedState assignment + _invalidateDerivedCaches() in
applySyncResponse with canonical _setMaterializedState() call. Derived caches
(adjacency, indexes, view) are now rebuilt rather than nulled, fixing C1
stale-read bug. Surface skippedWriters in syncWith() return. Remove unused
_invalidateDerivedCaches() method.

B106: Add isKnownOp() validation in SyncProtocol.applySyncResponse before
join(). Unknown ops throw SchemaUnsupportedError (fail closed), fixing C2
silent data loss.

B107: Add optional isAncestor() pre-check in processSyncRequest to detect
diverged writers without expensive chain walk. Falls back to loadPatchRange
throw for persistence layers without isAncestor. Updated misleading comment
in computeSyncDelta.
@coderabbitai
Copy link

coderabbitai bot commented Feb 27, 2026

Warning

Rate limit exceeded

@flyingrobots has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 23 minutes and 42 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

📥 Commits

Reviewing files that changed from the base of the PR and between 4b58403 and 7392f72.

📒 Files selected for processing (3)
  • CHANGELOG.md
  • src/domain/services/SyncController.js
  • test/unit/domain/services/SyncController.test.js
📝 Walkthrough

Walkthrough

Centralized canonical materialization was added: sync responses now install state via a new _setMaterializedState path, skippedWriters are surfaced through sync APIs, unknown patch ops are rejected, divergence checks use isAncestor when available, and WarpGraph join/GC/patch paths clear cached view hashes and update version vectors.

Changes

Cohort / File(s) Summary
Changelog & Roadmap
CHANGELOG.md, ROADMAP.md
Docs updated: changelog entries describing sync/join fixes and roadmap restructured (M12 SCALPEL, STANK.md absorption, execution reprioritization).
Type Declarations
index.d.ts, src/domain/WarpGraph.js, src/domain/warp/materializeAdvanced.methods.js
Added skippedWriters to Sync types; MaterializedGraph/MaterializedResult.stateHash now `string
Sync Controller
src/domain/services/SyncController.js
Introduced host _setMaterializedState(state) path; applySyncResponse/syncWith now return skippedWriters; apply path routes through _setMaterializedState rather than mutating caches directly; removed explicit derived-cache invalidation.
Sync Protocol
src/domain/services/SyncProtocol.js
Added isKnownOp validation to reject unknown ops with SchemaUnsupportedError; optional isAncestor pre-check to detect divergence and skip writers early; ancestry checks deferred when appropriate.
WarpGraph / Patch / GC
src/domain/warp/patch.methods.js, src/domain/warp/checkpoint.methods.js
join() now installs merged state as canonical _cachedState, clones observed frontier into _versionVector, rebuilds materializedGraph (adjacency + null stateHash), clears derived caches, and sets _stateDirty=false; _maybeRunGC and patch commit paths clear _cachedViewHash on relevant changes.
Tests
test/unit/domain/* (WarpGraph.coverageGaps.test.js, services/SyncController*.test.js, services/SyncProtocol.test.js)
Added/updated tests covering join cache coherence, _setMaterializedState invocation and materializedGraph rebuild, skippedWriters propagation, unknown-op rejection, and isAncestor divergence paths.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant SyncController
    participant SyncProtocol
    participant JoinReducer
    participant WarpGraph

    Client->>SyncController: applySyncResponse(response)
    SyncController->>SyncProtocol: validateOps(patches)
    SyncProtocol->>SyncProtocol: isKnownOp() check
    alt Unknown op found
        SyncProtocol-->>SyncController: SchemaUnsupportedError (throws)
        SyncController-->>Client: Error
    else All ops known
        SyncProtocol-->>SyncController: Valid patches
        SyncController->>JoinReducer: applyPatches(currentState, patches)
        JoinReducer-->>SyncController: newState
        SyncController->>WarpGraph: _setMaterializedState(newState)
        WarpGraph->>WarpGraph: rebuild adjacency/index, clear derived caches, set _stateDirty=false
        WarpGraph-->>SyncController: installed
        SyncController-->>Client: {state, skippedWriters, writersApplied,...}
    end
Loading
sequenceDiagram
    participant Client
    participant Process as processSyncRequest
    participant Persistence
    participant ChainWalker

    Client->>Process: processSyncRequest(range)
    Process->>Persistence: isAncestor(range.from, range.to)?
    alt isAncestor available and false
        Persistence-->>Process: false
        Process-->>Client: skip writer (divergence), record skippedWriters
    else isAncestor true or unavailable
        Persistence-->>Process: true/NA
        Process->>ChainWalker: loadPatchRange(range) (chain walk fallback)
        ChainWalker-->>Process: patch(es)
        Process-->>Client: patches
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

Possibly related PRs

Poem

🐇 I stitched the state and set it right,
Skipped the noisy writers out of sight.
Caches cleared, adjacencies grown,
Version vectors cloned and shown.
Hooray — no stale views on my hop tonight! 🌿✨

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the two main objectives: sync safety (M12.T1) and cache coherence (M12.T2), which are the primary changes across the changeset.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix/m12-t1-sync-safety

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 and usage tips.

@github-actions
Copy link

Release Preflight

  • package version: 12.2.0
  • prerelease: false
  • npm dist-tag on release: latest
  • npm pack dry-run: passed
  • jsr publish dry-run: passed

If you tag this commit as v12.2.0, release workflow will publish.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (2)
test/unit/domain/services/SyncController.test.js (1)

366-388: Add a rejection-path regression for _setMaterializedState.

This suite covers the success path well, but it doesn’t verify behavior when _setMaterializedState rejects. Please add a test that ensures applySyncResponse() does not leave partially updated bookkeeping (_lastFrontier, _patchesSinceGC) on failure.

🧪 Suggested test addition
+    it('does not advance frontier/counters when _setMaterializedState rejects', async () => {
+      const fakeState = {
+        observedFrontier: new Map(),
+        nodeAlive: { dots: new Map() },
+        edgeAlive: { dots: new Map() },
+      };
+      const newState = { observedFrontier: new Map(), nodeAlive: { dots: new Map() }, edgeAlive: { dots: new Map() } };
+      const previousFrontier = new Map([['alice', 'sha-1']]);
+      const nextFrontier = new Map([['alice', 'sha-2']]);
+      applySyncResponseMock.mockReturnValue({ state: newState, frontier: nextFrontier, applied: 2 });
+
+      const host = createMockHost({
+        _cachedState: fakeState,
+        _lastFrontier: previousFrontier,
+        _patchesSinceGC: 5,
+        _setMaterializedState: vi.fn().mockRejectedValue(new Error('install failed')),
+      });
+      const ctrl = new SyncController(/** `@type` {*} */ (host));
+
+      await expect(ctrl.applySyncResponse({ type: 'sync-response', frontier: {}, patches: [] }))
+        .rejects.toThrow('install failed');
+      expect(host._lastFrontier).toBe(previousFrontier);
+      expect(host._patchesSinceGC).toBe(5);
+    });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/unit/domain/services/SyncController.test.js` around lines 366 - 388, Add
a new unit test in the SyncController.test.js that simulates
_setMaterializedState rejecting and verifies that applySyncResponse leaves
bookkeeping unchanged (i.e., _lastFrontier and _patchesSinceGC remain their
pre-call values) when SyncController.applySyncResponse throws; mock
applySyncResponseMock to return a successful state/frontier, set
host._setMaterializedState to a mock that returns a rejected Promise, call await
expect(ctrl.applySyncResponse(...)).rejects.toThrow(), and then assert
host._lastFrontier and host._patchesSinceGC are equal to their original values
and that _materializedGraph was not set/changed; reference
_setMaterializedState, SyncController.applySyncResponse, _lastFrontier, and
_patchesSinceGC to locate the code under test.
src/domain/services/SyncController.js (1)

303-304: Align skippedWriters JSDoc shape with runtime payload.

applySyncResponse() and syncWith() now surface detailed skipped-writer entries (localSha, remoteSha), but syncWith() JSDoc currently types entries as { writerId, reason } only. Please make these signatures consistent so typed consumers can use the full payload.

Also applies to: 377-377

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/domain/services/SyncController.js` around lines 303 - 304, Update the
JSDoc return-type for both applySyncResponse() and syncWith() in
SyncController.js so the skippedWriters entry shape matches runtime payload;
change the typed element from just `{writerId, reason}` to include `localSha:
string` and `remoteSha: string|null` (e.g. `{writerId: string, reason: string,
localSha: string, remoteSha: string|null}`) in the Promise return annotation and
any other JSDoc references (also update the occurrence around the second
location noted near line 377) so consumers see the full payload.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/domain/services/SyncController.js`:
- Around line 337-347: The host bookkeeping (_lastFrontier and _patchesSinceGC)
is being updated before awaiting _setMaterializedState, which can leave
_cachedState inconsistent if install fails; fix by moving the two mutations
(this._host._lastFrontier = result.frontier and this._host._patchesSinceGC +=
result.applied) to after the await
this._host._setMaterializedState(result.state) so they only apply when
materialized state install succeeds (ensuring hasFrontierChanged/_cachedState
remain consistent); keep the await call and consider preserving current error
propagation behavior (no swallow) so failures still surface.

---

Nitpick comments:
In `@src/domain/services/SyncController.js`:
- Around line 303-304: Update the JSDoc return-type for both applySyncResponse()
and syncWith() in SyncController.js so the skippedWriters entry shape matches
runtime payload; change the typed element from just `{writerId, reason}` to
include `localSha: string` and `remoteSha: string|null` (e.g. `{writerId:
string, reason: string, localSha: string, remoteSha: string|null}`) in the
Promise return annotation and any other JSDoc references (also update the
occurrence around the second location noted near line 377) so consumers see the
full payload.

In `@test/unit/domain/services/SyncController.test.js`:
- Around line 366-388: Add a new unit test in the SyncController.test.js that
simulates _setMaterializedState rejecting and verifies that applySyncResponse
leaves bookkeeping unchanged (i.e., _lastFrontier and _patchesSinceGC remain
their pre-call values) when SyncController.applySyncResponse throws; mock
applySyncResponseMock to return a successful state/frontier, set
host._setMaterializedState to a mock that returns a rejected Promise, call await
expect(ctrl.applySyncResponse(...)).rejects.toThrow(), and then assert
host._lastFrontier and host._patchesSinceGC are equal to their original values
and that _materializedGraph was not set/changed; reference
_setMaterializedState, SyncController.applySyncResponse, _lastFrontier, and
_patchesSinceGC to locate the code under test.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 4c1f8e8 and 52233a4.

📒 Files selected for processing (12)
  • CHANGELOG.md
  • ROADMAP.md
  • src/domain/WarpGraph.js
  • src/domain/services/SyncController.js
  • src/domain/services/SyncProtocol.js
  • src/domain/warp/checkpoint.methods.js
  • src/domain/warp/materializeAdvanced.methods.js
  • src/domain/warp/patch.methods.js
  • test/unit/domain/WarpGraph.coverageGaps.test.js
  • test/unit/domain/services/SyncController.test.js
  • test/unit/domain/services/SyncController.trustGate.test.js
  • test/unit/domain/services/SyncProtocol.test.js

… assertion

H1: add skippedWriters to SyncResponse, ApplySyncResult, syncWith in index.d.ts
M1: fix misleading assertOpsCompatible comment in SyncProtocol
M2/M3: widen SyncController.syncWith @returns to full skippedWriters shape
L1: assert _stateDirty in dirty-path coverage gap test
L3: replace bare Function with (...args: unknown[]) => unknown
@github-actions
Copy link

Release Preflight

  • package version: 12.2.0
  • prerelease: false
  • npm dist-tag on release: latest
  • npm pack dry-run: passed
  • jsr publish dry-run: passed

If you tag this commit as v12.2.0, release workflow will publish.

Move _lastFrontier/_patchesSinceGC mutations after _setMaterializedState
await so a failed install does not leave bookkeeping advanced while
_cachedState remains stale. Add rejection-path regression test.

Addresses CodeRabbit review: 1 major (ordering), 1 nitpick (test),
1 nitpick (JSDoc — already fixed in prior commit).
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
CHANGELOG.md (1)

21-21: Consider moving this bullet to a Removed subsection.

Removed _invalidateDerivedCaches() is currently under ### Changed; categorizing it under ### Removed would improve changelog taxonomy consistency.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@CHANGELOG.md` at line 21, Move the bullet referencing
`_invalidateDerivedCaches()` from the "### Changed" section into a new or
existing "### Removed" subsection; update the heading grouping so the line "-
**Removed `_invalidateDerivedCaches()`** — replaced by canonical
`_setMaterializedState()` path; derived caches are now rebuilt rather than
nulled. (B105)" appears under "### Removed" and ensure surrounding section
ordering and numbering remain consistent with the changelog format.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@CHANGELOG.md`:
- Line 21: Move the bullet referencing `_invalidateDerivedCaches()` from the
"### Changed" section into a new or existing "### Removed" subsection; update
the heading grouping so the line "- **Removed `_invalidateDerivedCaches()`** —
replaced by canonical `_setMaterializedState()` path; derived caches are now
rebuilt rather than nulled. (B105)" appears under "### Removed" and ensure
surrounding section ordering and numbering remain consistent with the changelog
format.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 52233a4 and 4b58403.

📒 Files selected for processing (5)
  • CHANGELOG.md
  • index.d.ts
  • src/domain/services/SyncController.js
  • src/domain/services/SyncProtocol.js
  • test/unit/domain/WarpGraph.coverageGaps.test.js

@flyingrobots
Copy link
Member Author

@coderabbitai review please 🥕

@coderabbitai
Copy link

coderabbitai bot commented Feb 27, 2026

@flyingrobots: I'll review the PR now! This looks like a comprehensive fix for sync safety and cache coherence issues. I'll examine the changes across the sync protocol, state management, and cache handling. 🥕

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@github-actions
Copy link

Release Preflight

  • package version: 12.2.0
  • prerelease: false
  • npm dist-tag on release: latest
  • npm pack dry-run: passed
  • jsr publish dry-run: passed

If you tag this commit as v12.2.0, release workflow will publish.

@flyingrobots flyingrobots merged commit 7be4644 into main Feb 27, 2026
8 checks passed
@flyingrobots flyingrobots deleted the fix/m12-t1-sync-safety branch February 27, 2026 20:23
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