Skip to content

fix(T7): add _committed guard to PatchBuilderV2 — prevent use-after-commit#51

Merged
flyingrobots merged 9 commits intomainfrom
stank/worktree-b
Feb 28, 2026
Merged

fix(T7): add _committed guard to PatchBuilderV2 — prevent use-after-commit#51
flyingrobots merged 9 commits intomainfrom
stank/worktree-b

Conversation

@flyingrobots
Copy link
Member

@flyingrobots flyingrobots commented Feb 27, 2026

Summary

  • add _committed lifecycle flag to PatchBuilderV2
  • enforce _assertNotCommitted() at the start of all mutating APIs, including commit()
  • set _committed = true only after a successful commit
  • add a use-after-commit guard test block covering all requested post-commit and failed-commit behaviors

Verification

  • npx vitest run test/unit/domain/services/PatchBuilderV2.test.js
  • npx eslint src/domain/services/PatchBuilderV2.js
  • npx tsc --noEmit

Summary by CodeRabbit

  • New Features

    • Added post-commit guard to prevent accidental mutations to patch builders after commit operations.
  • Bug Fixes

    • Enhanced garbage collection error handling with improved diagnostics for invalid inputs.
    • Introduced runtime validation for patch operations to detect and report malformed data.
  • Tests

    • Expanded test suite with comprehensive coverage for error scenarios and edge cases across multiple validation paths.

@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 5 minutes and 54 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 06de4ae and 8ca4711.

📒 Files selected for processing (4)
  • BACKLOG.md
  • ROADMAP.md
  • src/domain/services/PatchBuilderV2.js
  • test/unit/domain/services/PatchBuilderV2.test.js
📝 Walkthrough

Walkthrough

The PR introduces error handling and runtime validation for patch operations and garbage collection processes. New PatchError class is added for malformed patches. Validation logic is integrated into JoinReducer for patch operations, GCPolicy for collection safety, and PatchBuilderV2 adds post-commit mutation guards. Comprehensive test coverage validates the new error paths.

Changes

Cohort / File(s) Summary
Error Class Definition
index.d.ts, src/domain/errors/PatchError.js, src/domain/errors/index.js
New PatchError class exported for malformed patch operations; mirrors existing error patterns with code and context fields.
Patch Operation Validation
src/domain/services/JoinReducer.js, test/unit/domain/services/JoinReducer.validation.test.js
Adds runtime validation for patch operations (NodeAdd, NodeRemove, EdgeAdd, EdgeRemove, PropSet) with field/type checks; validates ops before processing in applyOpV2, applyWithDiff, applyWithReceipt; comprehensive test suite validates error conditions and forward-compatibility.
GC Error Handling
src/domain/services/GCPolicy.js, test/unit/domain/services/GCPolicy.test.js
Enhances executeGC with appliedVV type validation and try/catch wrapper around orsetCompact calls; throws WarpError with phase context on failure; test verifies E_GC_INVALID_VV for non-Map input.
Patch Builder Refactoring
src/domain/services/PatchBuilderV2.js, test/unit/domain/services/PatchBuilderV2.test.js
Replaces _reads tracking with _observedOperands set; adds _committed flag with _assertNotCommitted() guard to prevent mutations after commit; public reads getter remains unchanged but derives from _observedOperands; test suite validates post-commit behavior across all mutating methods.
Documentation Updates
bin/warp-graph.js
Comment clarification around output-format pre-scanning and argv parsing order; no functional changes.

Sequence Diagram(s)

sequenceDiagram
    participant Caller
    participant JoinReducer
    participant validateOp
    participant applyOp as Operation<br/>Handler
    participant PatchError

    Caller->>JoinReducer: applyOpV2(operation)
    JoinReducer->>validateOp: validate(operation)
    
    alt Valid Operation
        validateOp-->>JoinReducer: validation passes
        JoinReducer->>applyOp: process operation
        applyOp-->>JoinReducer: returns result
        JoinReducer-->>Caller: result
    else Invalid Operation
        validateOp->>PatchError: throw(message, {code: E_PATCH_MALFORMED})
        PatchError-->>Caller: PatchError thrown
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Poem

🐰 A patch of new guards, so pristine and bright,
Validation flows check each operation right,
Observed operands dance when commits arise,
Error messages bloom in the code's disguise, 🌱
Safer patches hop through the warp 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 main change: adding a _committed guard to PatchBuilderV2 to prevent use-after-commit, which directly aligns with the primary objective and the most substantial change in the PR.
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 stank/worktree-b

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.

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: 3

🧹 Nitpick comments (4)
src/domain/services/GCPolicy.js (1)

124-129: Preserve the original error details when wrapping compaction failures.

Current catch {} loses the root cause, making E_GC_COMPACT_FAILED harder to debug in production incidents.

🛠️ Proposed improvement
-  } catch {
+  } catch (cause) {
     throw new WarpError(
       `GC compaction failed during ${nodesDone ? 'edgeAlive' : 'nodeAlive'} phase`,
       'E_GC_COMPACT_FAILED',
-      { context: { phase: nodesDone ? 'edgeAlive' : 'nodeAlive', partialCompaction: nodesDone } },
+      {
+        context: {
+          phase: nodesDone ? 'edgeAlive' : 'nodeAlive',
+          partialCompaction: nodesDone,
+          cause: cause instanceof Error ? cause.message : String(cause),
+        },
+      },
     );
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/domain/services/GCPolicy.js` around lines 124 - 129, The catch block in
GCPolicy.js currently swallows the original exception; change the bare catch to
catch (err) and include the original error when throwing the WarpError (e.g.,
pass err as a cause or attach it to the context) so the thrown
'E_GC_COMPACT_FAILED' retains the root-cause details; update the throw site that
constructs WarpError (the same place using nodesDone and 'edgeAlive'/'nodeAlive'
phase) to include the captured error under a consistent property like cause or
context.originalError.
src/domain/services/JoinReducer.js (1)

707-712: Avoid double-validating every op in diff/receipt paths.

applyWithDiff and applyWithReceipt validate ops before calling applyOpV2, but applyOpV2 already validates at Line 213. Keeping one validation path avoids unnecessary overhead and drift.

♻️ Proposed simplification
-    validateOp(/** `@type` {Record<string, unknown>} */ (op));
     const eventId = createEventId(patch.lamport, patch.writer, patchSha, i);
@@
-    validateOp(/** `@type` {Record<string, unknown>} */ (op));
     const eventId = createEventId(patch.lamport, patch.writer, patchSha, i);

Also applies to: 736-768

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

In `@src/domain/services/JoinReducer.js` around lines 707 - 712, The code is
double-validating ops: callers applyWithDiff and applyWithReceipt already call
validateOp before invoking applyOpV2, but applyOpV2 also validates (around the
current Line 213); remove the redundant validateOp call from applyOpV2 so
validation happens only in the caller paths (applyWithDiff/applyWithReceipt),
leaving applyOpV2 to assume its input is already validated; ensure tests/callers
still pass valid ops (references: validateOp, applyWithDiff, applyWithReceipt,
applyOpV2, snapshotBeforeOp, accumulateOpDiff, createEventId).
test/unit/domain/services/JoinReducer.validation.test.js (1)

79-231: Add compatibility tests for real historical op shapes.

Given current CI failures, include explicit non-throw cases for payloads like observedDots: Set<string> and accepted legacy/encoded dot forms. That would prevent reintroducing this regression.

🧪 Suggested test additions
+    it('accepts NodeRemove with observedDots as Set (backward compatibility)', () => {
+      expect(() => applyOpV2(state(), /** `@type` {any} */ ({
+        type: 'NodeRemove',
+        observedDots: new Set(),
+      }), eid)).not.toThrow();
+    });
+
+    it('accepts NodeAdd with encoded/legacy dot shape used by existing patches', () => {
+      expect(() => applyOpV2(state(), /** `@type` {any} */ ({
+        type: 'NodeAdd',
+        node: 'n',
+        dot: 'w1:1',
+      }), eid)).not.toThrow();
+    });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/unit/domain/services/JoinReducer.validation.test.js` around lines 79 -
231, Add compatibility tests that assert applyOpV2/reduceV5 do NOT throw for
historical op shapes: add cases where observedDots is a Set<string> (for
NodeRemove and EdgeRemove) and where dot uses legacy/encoded forms (e.g.,
string/legacy-object representations) for NodeAdd/EdgeAdd; place them alongside
the existing C3 per-op-type tests and the fast-path tests so they call
applyOpV2(state(), /* historical payload */ , eid) and reduceV5([...entry]) and
assert not.toThrow(), referencing the existing test helpers createEmptyStateV5,
createEventId, applyOpV2 and reduceV5.
test/unit/domain/services/PatchBuilderV2.test.js (1)

717-810: Extend guard tests to cover async mutators and concurrent commit attempts.

Current coverage is good for post-commit sync calls, but it misses attachContent/attachEdgeContent and Promise.all([builder.commit(), builder.commit()]) re-entrancy behavior.

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

In `@test/unit/domain/services/PatchBuilderV2.test.js` around lines 717 - 810, Add
tests in the "use-after-commit guard" suite to (1) assert async mutators throw
after commit by calling attachContent(...) and attachEdgeContent(...) on a
committed PatchBuilderV2 and expecting the same "PatchBuilder already committed
— create a new builder" error, and (2) assert commit re-entrancy behavior by
invoking Promise.all([builder.commit(), builder.commit()]) on a fresh builder
and asserting that only one commit proceeds (builder._committed becomes true)
and the concurrent/second promise rejects (expect the rejected promise to throw
or resolve to an error indicating the builder was already committed); reference
PatchBuilderV2, attachContent, attachEdgeContent, and commit when adding these
tests.
🤖 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/JoinReducer.js`:
- Around line 125-131: The requireArray guard in requireArray currently rejects
non-Array values (throwing PatchError) but should accept iterable collections
used in valid payloads (e.g., Set) so NodeRemove/EdgeRemove don't break; update
requireArray to allow Array or iterable objects (or specifically Set) and, if
given a Set/iterable, coerce op[field] to an Array before returning/using it
(preserve existing PatchError for truly non-iterable values) so downstream code
that expects an array receives one; reference requireArray and PatchError to
locate and modify the check and conversion logic.
- Around line 138-157: The requireDot validator is too strict: instead of always
throwing when op.dot isn't an object with string writerId and numeric counter,
relax it to accept legacy shapes by (1) if dot is a string, attempt to parse
JSON and fall back to treating the string as a writerId with counter 0; (2) if
dot is an object but lacks writerId, accept alternative identifier fields (e.g.,
id, writer) or coerce a missing writerId to a string representation and default
counter to 0; (3) only throw a PatchError from requireDot when after coercion
writerId is not a string or counter is not a number. Update the PatchError
context in requireDot to include the original op.dot value and the coerced
result for debugging while keeping existing op.type context.

In `@src/domain/services/PatchBuilderV2.js`:
- Around line 173-174: The current commit() and mutator guards rely solely on
this._committed which is flipped only after async awaits, leaving a re-entrancy
window; add an in-flight lock (e.g., this._committing boolean) and update the
guard checks (places referencing this._committed inside commit() and relevant
mutator methods) to reject if either this._committed or this._committing is
true, set this._committing = true at the start of the async commit flow before
any awaits, and ensure you clear it in a finally block (and flip this._committed
on success) so concurrent calls cannot pass the guard during the async window.

---

Nitpick comments:
In `@src/domain/services/GCPolicy.js`:
- Around line 124-129: The catch block in GCPolicy.js currently swallows the
original exception; change the bare catch to catch (err) and include the
original error when throwing the WarpError (e.g., pass err as a cause or attach
it to the context) so the thrown 'E_GC_COMPACT_FAILED' retains the root-cause
details; update the throw site that constructs WarpError (the same place using
nodesDone and 'edgeAlive'/'nodeAlive' phase) to include the captured error under
a consistent property like cause or context.originalError.

In `@src/domain/services/JoinReducer.js`:
- Around line 707-712: The code is double-validating ops: callers applyWithDiff
and applyWithReceipt already call validateOp before invoking applyOpV2, but
applyOpV2 also validates (around the current Line 213); remove the redundant
validateOp call from applyOpV2 so validation happens only in the caller paths
(applyWithDiff/applyWithReceipt), leaving applyOpV2 to assume its input is
already validated; ensure tests/callers still pass valid ops (references:
validateOp, applyWithDiff, applyWithReceipt, applyOpV2, snapshotBeforeOp,
accumulateOpDiff, createEventId).

In `@test/unit/domain/services/JoinReducer.validation.test.js`:
- Around line 79-231: Add compatibility tests that assert applyOpV2/reduceV5 do
NOT throw for historical op shapes: add cases where observedDots is a
Set<string> (for NodeRemove and EdgeRemove) and where dot uses legacy/encoded
forms (e.g., string/legacy-object representations) for NodeAdd/EdgeAdd; place
them alongside the existing C3 per-op-type tests and the fast-path tests so they
call applyOpV2(state(), /* historical payload */ , eid) and reduceV5([...entry])
and assert not.toThrow(), referencing the existing test helpers
createEmptyStateV5, createEventId, applyOpV2 and reduceV5.

In `@test/unit/domain/services/PatchBuilderV2.test.js`:
- Around line 717-810: Add tests in the "use-after-commit guard" suite to (1)
assert async mutators throw after commit by calling attachContent(...) and
attachEdgeContent(...) on a committed PatchBuilderV2 and expecting the same
"PatchBuilder already committed — create a new builder" error, and (2) assert
commit re-entrancy behavior by invoking Promise.all([builder.commit(),
builder.commit()]) on a fresh builder and asserting that only one commit
proceeds (builder._committed becomes true) and the concurrent/second promise
rejects (expect the rejected promise to throw or resolve to an error indicating
the builder was already committed); reference PatchBuilderV2, attachContent,
attachEdgeContent, and commit when adding these tests.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 7be4644 and 06de4ae.

📒 Files selected for processing (10)
  • bin/warp-graph.js
  • index.d.ts
  • src/domain/errors/PatchError.js
  • src/domain/errors/index.js
  • src/domain/services/GCPolicy.js
  • src/domain/services/JoinReducer.js
  • src/domain/services/PatchBuilderV2.js
  • test/unit/domain/services/GCPolicy.test.js
  • test/unit/domain/services/JoinReducer.validation.test.js
  • test/unit/domain/services/PatchBuilderV2.test.js

@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.

@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
Copy link
Member Author

@coderabbitai Thanks for the detailed review. Clarifying the two JoinReducer threads:

Both are outdated against the current PR head.

Evidence:

  • Current net diff vs base (origin/main...HEAD) includes only:
    • src/domain/services/PatchBuilderV2.js
    • test/unit/domain/services/PatchBuilderV2.test.js
  • No src/domain/services/JoinReducer.js changes are present in this PR.

The PatchBuilder re-entrancy guard thread is already resolved and implemented (_committing lock + tests).

If JoinReducer compatibility regressions reappear, I’ll handle them in a dedicated PR scoped to reducer behavior.

@flyingrobots
Copy link
Member Author

@coderabbitai review please 🥕

@coderabbitai
Copy link

coderabbitai bot commented Feb 27, 2026

@flyingrobots, thank you for the clarification! I can see the PR is now focused on the PatchBuilderV2 lifecycle guard changes. I'll review the current state of the PR, concentrating on:

  • The _committed flag and _assertNotCommitted() guard implementation
  • The use-after-commit test coverage
  • The re-entrancy protection with _committing lock you mentioned

Let me take a fresh look at the changes now. 🥕

✅ 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.

@coderabbitai
Copy link

coderabbitai bot commented Feb 27, 2026

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check the current PR files changed
gh pr view 51 --json files --jq '.files[] | "\(.path): +\(.additions)/-\(.deletions)"'

Length of output: 106


🏁 Script executed:

#!/bin/bash
# Check if JoinReducer.js has changes in this PR
gh pr diff 51 --name-only | grep -i joinreducer

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Get the current PR head SHA and list of changed files
gh pr view 51 --json headRefOid,files --jq '{head: .headRefOid, files: [.files[].path]}'

Length of output: 152


@flyingrobots You're absolutely right! I can confirm the current PR head (f6843d7) only includes changes to:

  • src/domain/services/PatchBuilderV2.js (+145/-110)
  • test/unit/domain/services/PatchBuilderV2.test.js (+137/-0)

No JoinReducer changes are present in this PR. Those review threads are indeed outdated and no longer applicable to the current scope.

The _committed guard implementation looks solid:

  • _assertNotCommitted() checks before all mutations
  • ✅ Flag set after successful commit
  • ✅ Comprehensive test coverage including failed commit scenarios

Your approach to keep this PR tightly scoped to the use-after-commit guard and handle any future reducer concerns separately is the right call. Would you like me to resolve those outdated JoinReducer threads for you?

@coderabbitai
Copy link

coderabbitai bot commented Feb 27, 2026

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check the actual files changed in PR `#51`
gh pr diff 51 --name-only

Length of output: 86


🏁 Script executed:

#!/bin/bash
# Get more detailed diff stats for PR `#51`
gh pr diff 51 --stat

Length of output: 351


🏁 Script executed:

#!/bin/bash
# Check if PatchBuilderV2.js contains _committing or _committed
cat src/domain/services/PatchBuilderV2.js | grep -n "_commit"

Length of output: 220


@flyingrobots Confirmed! The actual PR diff shows only the two PatchBuilderV2 files:

  • src/domain/services/PatchBuilderV2.js
  • test/unit/domain/services/PatchBuilderV2.test.js

You're absolutely right—the JoinReducer threads are outdated and not relevant to the current PR state. I can also see that the implementation includes both lifecycle guards:

  1. _committed (lines 174, 202, 726) — prevents use-after-commit
  2. _committing (lines 177, 202, 616, 729) — prevents re-entrant calls during async commit

Your approach to keep this PR tightly scoped to PatchBuilderV2 and handle any JoinReducer compatibility concerns in a separate PR is sound. Apologies for any confusion caused by the outdated summary context. The current implementation looks solid! 🎯

@flyingrobots
Copy link
Member Author

@coderabbitai review please 🥕\n\nRequesting an additional maintainer review for merge quorum.

@coderabbitai
Copy link

coderabbitai bot commented Feb 27, 2026

@flyingrobots I'll review the changes now! 🥕

I can see the PR is tightly focused on the PatchBuilderV2 lifecycle guards with both _committed and _committing protections in place. The test coverage looks comprehensive, including the use-after-commit scenarios and failed commit cases.

Regarding the maintainer review for merge quorum—I can provide the automated review, but you're right that you'll need human maintainer approval as well for your merge requirements.

✅ 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.

@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
Copy link
Member Author

@coderabbitai review please 🥕

@coderabbitai
Copy link

coderabbitai bot commented Feb 27, 2026

@flyingrobots I'll review the changes now! 🥕

✅ 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.

@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
Copy link
Member Author

Maintainer review requested for merge quorum (target: >=2 non-bot reviews). Current branch tip is c1140e6 with backlog follow-up automation tasks only.

@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.

@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 2f176d6 into main Feb 28, 2026
8 checks passed
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