Skip to content

fix: mark an op published at its record ack and mirror the gate stage 2 on the produce side - #1058

Merged
FSM1 merged 4 commits into
mainfrom
fix/1045-drain-publish-integrity
Aug 5, 2026
Merged

fix: mark an op published at its record ack and mirror the gate stage 2 on the produce side#1058
FSM1 merged 4 commits into
mainfrom
fix/1045-drain-publish-integrity

Conversation

@FSM1

@FSM1 FSM1 commented Aug 4, 2026

Copy link
Copy Markdown
Owner

Two gaps in the write plane's fail-closed publish path, both extending what landed tonight in #1046 and #1049.

The mark's line is the ack, not the self-adopt

publish_node returns Ok only once the self-adopt has also succeeded, so a record whose PUT confirmed and whose adopt then failed left the op queued with the published-op mark unraised. On the next boot that op replayed, re-uploaded the identical content-addressed leaves, and a cancel landing in that transfer reached retire_cancelled and unpinned content the live record already names. Within one session the publish-entry interlock covers it; across a reboot UploadCancels is empty, so it does not.

The mark now rises inside publish_node the instant publish_head returns, which it does only on PublishOutcome::Published. An unconfirmed or race-losing publish raises nothing: those bytes may never have landed, and dropping the op on the strength of that would be loss.

The decision the issue flagged as open is which record of a plan marks. It is the plan's last: updateContent marks on the target's own publish, create on the parent-folder publish, and every other publish passes None. Marking the create's child publish would drop an op on restart whose child no parent names, so the negative direction is pinned by its own test.

The produce mirror covered stage 3 but not stage 2

check_scope_root verified the commitment's ipnsName binding and every structure signature, but never commitment_sig. authenticate_section_structures authenticates structures against the pseudonyms the section's own commitment names, so a wholly self-consistent attacker-authored section — own commitment, own pseudonym, own signatures, correct ipnsName — passed it completely. The drain reads its scope root from the snapshot cache, which is floor-checked but never re-verifies the section, so a tampered entry was caught for internal consistency and not for owner authority.

check_scope_root now runs verify_grant_set at the gate's own stage-2 position, release-active, on both scope-root authoring paths. Reject rows for a foreign-signed commitment, an unusable signature, and the re-seal path's own equivalent, all returning Err so they fire in a release build.

Produce-side refusals also carry stable check names — AuthorError::check, rendered by Display — and the drain charges the trust class against the attempt budget instead of retrying it free forever.

What differs from the issue bodies

  • #1048 asks for a permanent verdict. These refusals are charged instead. On the drain path they can only come from the cached scope root, which a later resolve replaces, so an immediate dead-letter would abandon a user's ops over a cache another tick repairs — the mirror-image of the bug being fixed. The budget bounds the spin either way, and Seal/HeadTooLarge stay availability because a rebase may not build that body again.
  • #1048's second half also names net/rotation.rs. That file is a sibling PR's, so this branch makes only the one-argument call-site change the new check_scope_root signature forces; the classification there is engine: classify rotation-path authoring refusals instead of flattening them to not-published #1055.
  • #1045's MissingGrantSection/GrantSectionOnChild are unreachable from the drain today — a gated child never carries a section and a gated root always does — but they are classified with the rest rather than left as the one uncharged arm.

Also folded in from the review gates

A reload that lands on a rotated root is now refused rather than authored at the epoch the pass opened on: that skew is a self-healing race, and the new classification would otherwise spend the op's budget on it. Two invariants the trust class made stale are corrected — an authoring refusal is raised before its own record reaches the transport, not before every PUT of the op, and the abandonment retires on the target still being unreachable rather than on no PUT having acked.

Deferred

#1055 rotation-path classification, #1056 surfacing the refusal name past AttemptsExhausted, #1057 the ascent-link half of stage 3 on the re-seal's produce side. Each carries a native dependency edge on #1048.

Closes #1045
Closes #1048

Summary by CodeRabbit

  • Bug Fixes

    • Improved validation of scope-root signatures and owner identities.
    • Added clearer handling for invalid or unauthorized commitment signatures.
    • Prevented replaying operations after confirmed publication, including after crashes or restarts.
    • Preserved queued operations when final publication does not complete.
    • Improved handling of moves, deletions, renames, and partial publication failures.
    • Improved classification of authoring refusals during publishing.
  • Tests

    • Added coverage for signature mismatches, malformed sections, publication recovery, replay prevention, and publication failures.

Note

Mark ops published at record PUT ack and verify commitment signatures on scope-root authoring

  • Published-op marks are now raised at the instant the last record's PUT confirms (in publish_node), rather than after self-adopt; this improves crash safety and prevents redundant re-uploads on restart.
  • Scope-root authoring in check_scope_root now performs stage-2/3 validation: decodes the grant section, verifies the commitment signature against the owner identity, checks ipns_name match, and authenticates structure signatures. Two new AuthorError variants (InvalidGrantSection, CommitmentSignatureInvalid) surface these failures.
  • Cross-folder move compensation in publish_ref_move no longer rolls back a dest-add if the source-remove already confirmed; compensation is gated on the PublishHalt.confirmed flag introduced by the new PublishHalt struct.
  • Mid-pass scope-root rotation is now detected in reload_folder: if the loaded epoch differs from the pass epoch, the pass halts and retries rather than authoring with a stale epoch.
  • Risk: ops that previously marked after self-adopt now mark earlier (on PUT ack); any crash between PUT ack and self-adopt no longer causes a replay, but callers that expected the old ordering may observe the mark sooner.

Macroscope summarized 91cdac1.

FSM1 and others added 2 commits August 5, 2026 00:59
…ge 2 on the produce side

Two gaps in the write plane's fail-closed publish path.

A publish whose record PUT acked and whose self-adopt then failed left the op
queued with the published-op mark unraised, so a restart replayed it, re-uploaded
the identical leaves, and a cancel landing in that transfer unpinned content the
live record already names. The mark now rises inside the confirmed-publish arm of
publish_head, on the last record of an op's plan: the ack is the moment the
version stops being replayable, not the adopt. An unconfirmed or race-losing
publish still raises nothing, and neither does a record the plan will follow with
another - a create marks on its parent, so an op is never dropped with a child no
parent names.

The scope-root produce mirror verified the commitment's ipnsName binding and
every structure signature but never the owner's signature over the commitment
itself. authenticate_section_structures authenticates structures against the
pseudonyms the section's own commitment names, so a wholly self-consistent
attacker-authored section passed. The drain reads its scope root from the
snapshot cache, which is floor-checked but not re-verified, so a tampered entry
was caught for internal consistency and not for owner authority. check_scope_root
now runs verify_grant_set at the gate's own stage-2 position, release-active, on
both scope-root authoring paths (AGENTS.md rule 8).

Produce-side refusals also carry stable check names, and the drain charges the
trust class against the attempt budget instead of retrying it free forever. They
are charged rather than dead-lettered on sight: on the drain path these inputs
are the cached scope root, which a later resolve replaces, so an immediate
permanent verdict would abandon a user's ops over a cache another tick repairs.

Closes #1045
Closes #1048

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…rror fix

/simplify, /security-review and /crypto-privacy-review on the branch diff.

- Raise the mark in publish_node rather than threading an op id into
  publish_head, which returns Ok only on a confirmed publish anyway. Same
  instant, one fewer parameter and no clippy suppression.
- Pin the other half of the mark's rule: a create whose child published and
  whose parent never did keeps the mark down, so it stays replayable rather
  than stranding a live child no parent names. Nothing asserted that before,
  and it is the direction that loses data.
- Refuse a reload that lands on a rotated root instead of authoring at the
  epoch the pass opened on. That skew is a self-healing race, and the new
  classification would otherwise spend the op's budget on it.
- Correct two invariants the trust class made stale: an authoring refusal is
  raised before its own record reaches the transport, not before every PUT of
  the op, and the abandonment retires on the target still being unreachable
  rather than on no PUT having acked.
- Route AuthorError's Display through check(), so the refusal name is the one
  surface rather than a second table to keep in sync, and state where the
  produce mirror stops - stage 3's ascent-link cross-check takes a reader
  secret the authoring inputs do not carry.
- Generalize the floor fake's fault to every raise, keep it to one lock, and
  share the drop-without-replay assertions between the two mark tests.

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

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

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

Next review available in: 4 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

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: cc0d5f71-1042-44c4-b24a-c35a39a9e598

📥 Commits

Reviewing files that changed from the base of the PR and between 2883189 and 91cdac1.

📒 Files selected for processing (2)
  • crates/engine/src/testkit/fakes/record_store.rs
  • crates/engine/tests/write_plane.rs

Walkthrough

Scope-root authoring now verifies owner-authorized commitments and reports stable refusal types. Drain publishing marks operations after final record confirmation, tracks confirmation state, and classifies trust refusals. Test fakes and write-plane tests cover failure injection, compensation, replay suppression, and retries.

Changes

Scope-root validation and publishing

Layer / File(s) Summary
Owner-authorized scope-root authoring
crates/engine/src/net/author.rs, crates/engine/src/net/rotation.rs
Authoring accepts the owner verifier, validates grant-section decoding, commitment authorization, names, and structure signatures. AuthorError exposes stable names and trust classification. Rotation passes the owner identity.
Final-record completion and refusal handling
crates/engine/src/sync/drain.rs
Publishing passes completion markers to final records, raises them after confirmation and before self-adoption, preserves confirmation state for compensation, and classifies trust refusals as upload attempts.
Failure injection and replay regression coverage
crates/engine/src/testkit/fakes/floor_store.rs, crates/engine/src/testkit/fakes/record_store.rs, crates/engine/tests/write_plane.rs
Test stores inject floor and routing-key PUT failures. Tests cover atomic floor failures, replay suppression, incomplete plans, compensation, delete, rename, and cross-folder moves.

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

Possibly related issues

Possibly related PRs

Suggested labels: comp:engine

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning The PR meets #1045 and the drain-side parts of #1048, but it leaves rotation-path refusal classification deferred. Classify permanent AuthorError trust refusals at the rotation call site and expose stable check names there.
✅ Passed checks (4 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 published-operation marking fix and produce-side stage-2 verification changes.
Out of Scope Changes check ✅ Passed The code and tests support published-operation marking, refusal classification, stage-2 verification, and related failure handling.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ 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 fix/1045-drain-publish-integrity

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.

@FSM1
FSM1 marked this pull request as ready for review August 5, 2026 00:32

@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: 2

🤖 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 `@crates/engine/src/net/author.rs`:
- Around line 223-226: Update the grant-section decoding path around
decode_grant_section to map malformed data to a distinct stable AuthorError
variant instead of AuthorError::Seal, mark that variant as a trust refusal in
AuthorError::is_trust_refusal, and add a regression test covering malformed
grantSection classification and attempt-budget charging.

In `@crates/engine/src/sync/drain.rs`:
- Line 1070: Update the publish_folder calls in
crates/engine/src/sync/drain.rs:1070-1070, :1157-1159, and :1172-1175 to mark
final records with Some(applied.op_id): always for the delete record, only when
source == dest for the destination record, and for the source-removal record
when source != dest. Add restart tests covering delete and both reference-move
shapes when final self-adoption fails.
🪄 Autofix

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: 6ab961de-054c-4403-8e62-a866a0f18729

📥 Commits

Reviewing files that changed from the base of the PR and between e935078 and 73eb14c.

📒 Files selected for processing (5)
  • crates/engine/src/net/author.rs
  • crates/engine/src/net/rotation.rs
  • crates/engine/src/sync/drain.rs
  • crates/engine/src/testkit/fakes/floor_store.rs
  • crates/engine/tests/write_plane.rs

Comment thread crates/engine/src/net/author.rs Outdated
Comment thread crates/engine/src/sync/drain.rs Outdated
@FSM1
FSM1 marked this pull request as draft August 5, 2026 00:37
…very plan

CodeRabbit's two Major findings on the PR, plus a data-loss window the review
gates found in the second one's fix.

A carried grantSection that fails to decode was an AuthorError::Seal, which
is_trust_refusal reports false for, so the drain retried it free forever. The
same bytes are a whole-record reject on arrival, so they are a trust refusal on
the produce side too: a distinct InvalidGrantSection with its own stable check
name. The narrowing is exactly the carried-bytes call site. The other three Seal
sites describe the body this pass built, which a rebase onto other state may not
build again, so they stay availability - reclassifying those would be the
mirror-image bug.

Delete and reference-move plans passed None throughout, leaving the window #1045
closes still open for those two shapes. Each now marks its plan's last record:
the delete's only record, the reference move's dest-add when source and
destination are one folder, and the source-remove when they are not. Verified
against the code rather than the review's word, and the dest-add's non-marking
half now has its own test - the existing move test passes either way, so nothing
was pinning the split.

That fix opened a window of its own. The compensation fires on any error from
the source-remove, including one raised after the record confirmed, and it
decides whether to roll back by re-reading the source - a read the failed publish
just left stale in the cache, where the dest arm of the same function
deliberately reads the network. With the mark up, a wrong rollback would drop the
op on restart with the move undone and never retried. A publish failure now
carries whether its record confirmed, and a confirmed source-remove is never
compensated: the fact the compensation infers is one the publish already has.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@FSM1
FSM1 marked this pull request as ready for review August 5, 2026 05:01
@FSM1
FSM1 marked this pull request as draft August 5, 2026 05:03

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

🧹 Nitpick comments (1)
crates/engine/src/testkit/fakes/record_store.rs (1)

108-116: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider adding a heal_put_for counterpart.

fail_endpoint and fail_put_endpoint each pair with a heal method. fail_put_for has none, so a test cannot inject a source-remove failure and then let a retry complete the plan in the same FakeWorld. The current tests do not need it. Add it when a retry-completion test arrives.

♻️ Proposed heal counterpart
     pub fn fail_put_for(&self, routing_key: &str) {
         self.put_failing_keys
             .lock()
             .expect("lock")
             .insert(routing_key.to_owned());
     }
+
+    /// Restore `routing_key`'s PUT path.
+    pub fn heal_put_for(&self, routing_key: &str) {
+        self.put_failing_keys.lock().expect("lock").remove(routing_key);
+    }
🤖 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 `@crates/engine/src/testkit/fakes/record_store.rs` around lines 108 - 116, Add
a heal_put_for counterpart next to fail_put_for that removes the routing key
from put_failing_keys, matching the existing fail/heal pairing and allowing
retries to complete within the same FakeWorld.
🤖 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.

Nitpick comments:
In `@crates/engine/src/testkit/fakes/record_store.rs`:
- Around line 108-116: Add a heal_put_for counterpart next to fail_put_for that
removes the routing key from put_failing_keys, matching the existing fail/heal
pairing and allowing retries to complete within the same FakeWorld.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 64959b6b-a17c-4b8d-907f-ccb74a19030a

📥 Commits

Reviewing files that changed from the base of the PR and between 73eb14c and 2883189.

📒 Files selected for processing (4)
  • crates/engine/src/net/author.rs
  • crates/engine/src/sync/drain.rs
  • crates/engine/src/testkit/fakes/record_store.rs
  • crates/engine/tests/write_plane.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • crates/engine/src/net/author.rs

…lands

The dest-add test asserted the move stays queued for the retry that
completes it without ever running that retry. Heal the source-remove's
PUT and drive the next drain pass, so the claim is proven rather than
stated, and pair fail_put_for with the heal that path needs.
@FSM1

FSM1 commented Aug 5, 2026

Copy link
Copy Markdown
Owner Author

Consider adding a heal_put_for counterpart. … The current tests do not need it. Add it when a retry-completion test arrives.

Addressed in 91cdac1 — but not as a bare helper, because an unused pub fn in a pub mod testkit raises no dead_code warning and would have rotted silently. Adding it alone is exactly the speculative generality the /simplify gate strips.

The asymmetry was real, though, and it was pointing at a genuine coverage gap rather than a missing method. a_cross_folder_moves_dest_add_never_marks_on_its_own closes on:

"the move stays queued for the retry that completes it"

…and then never runs that retry. The claim was asserted, not proven.

So the retry-completion test you said to wait for is in this commit, and it is what justifies the helper:

  • crates/engine/src/testkit/fakes/record_store.rs:117heal_put_for, and fail_put_for's doc now names it the way the other two fail/heal pairs do.
  • crates/engine/tests/write_plane.rs:4700a_retried_cross_folder_move_marks_when_its_source_remove_lands: fail the root's PUT, confirm the compensated attempt leaves the mark down, heal, drive one more drain pass, then assert the dest-add republishes, the mark advances to the op, and the op leaves the queue.

It passed first run, so the engine already behaved as the original test claimed — the gap was in the proof, not the code.

cargo fmt --all --check and cargo clippy --workspace --all-targets are clean; cipherbox-engine is 890 green, write_plane 89 → 90.

@FSM1
FSM1 marked this pull request as ready for review August 5, 2026 08:12
@FSM1
FSM1 merged commit c85ca11 into main Aug 5, 2026
24 checks passed
@FSM1
FSM1 deleted the fix/1045-drain-publish-integrity branch August 5, 2026 08:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant