Skip to content

fix(engine): retire the head block a publish orphaned before the transport - #944

Merged
FSM1 merged 4 commits into
mainfrom
fix/921-retire-orphaned-head-blocks
Aug 2, 2026
Merged

fix(engine): retire the head block a publish orphaned before the transport#944
FSM1 merged 4 commits into
mainfrom
fix/921-retire-orphaned-head-blocks

Conversation

@FSM1

@FSM1 FSM1 commented Aug 1, 2026

Copy link
Copy Markdown
Owner

Problem

Every record publish uploads its head block through POST /content/upload — the same charged ingress a content block goes through — so each attempt creates its own accountable PinnedCid row, sized and counted against sumHostedBytes.

A publish that failed before the record reached the transport left that row charged against an account that can never reach the block again. Nothing retired it, for any op kind: #916 taught abandonment to retire the write name and every content block, but Drain::registered_by names only those two, and folder creates, deletes, renames and moves have no content blocks at all.

It compounded per attempt. The drain draws a fresh seal nonce per pass, so each retry re-authored byte-different head bytes under a different CID — an op that burned ATTEMPT_BUDGET could leave five orphaned charged rows, and an op that retried three times and then succeeded leaked three, with no abandonment ever coming to clean up.

Change

crates/engine/src/sync/drain.rs notes the head CID of any publish that stopped short of the PUT fan-out and retires the set at the end of the pass that orphaned it, independent of the op's fate.

  • orphaned_head(&RecordPublishError) is the predicate — an exhaustive match, so a new PublishError variant is a compile error rather than a silent default.
    • Publish(Register | FloorRead) and HeadCidMismatch → orphan. All three are raised with the head block already uploaded and no record on the wire. Publish(EmptyHeadCid) → not an orphan: nothing was ever addressed, so there is no CID to retire.
    • Upload(Transport | Decode) → orphan. A dropped connection or an unreadable 2xx may have left a pin the server committed and the client never learned about; no record was PUT, so retiring is safe. Upload(Status | Unauthorized | …) → not an orphan: a status answer is the server's own refusal, so it charged no row.
    • Publish(AllEndpointsFailed)not an orphan. This is the decision the issue asked for on the acked-PUT arm. AllEndpointsFailed says no endpoint acked, not that none stored; a lost ack leaves a record resolvable at the name pointing at that head, and unpinning it is the loss engine: retire every uploaded content block on abandonment, not only the version root #916 refuses, where leaving the row charged is only a leak. The security gate caught this — the first cut retired on that arm and would have turned a quota leak into an unreadable node.
  • record_orphan_head refuses any CID the live held set still names, whatever the error said. The queue's only consumer physically unpins, so the destructive step checks the live set itself rather than trusting its caller to hand it a freshly authored head.
  • The pending set lives on the engine (Engine::orphan_heads), session-lived, so a retire the registry refused goes out again on a later pass. It is capped at RETIRE_BATCH_MAX so a session whose retires keep failing bounds its leak, not its memory.
  • Drain::run splits into drain_queue + retire_orphan_heads, so the retire fires on every exit from a pass, including the early ones.
  • blueprint/engine.md "Retirement" gains the rule.

Retiring eagerly, per pass, rather than accumulating a durable per-op list for the abandonment batch, is a deliberate divergence from the issue's literal wording. It covers strictly more: an op that never abandons still orphaned rows, and there is no new staging key, format tag, or pruning pass to keep correct. The observable outcome the issue asks for — every head CID an op's retries minted leaves the inventory, plus the write name on abandonment — is what the tests assert.

The issue's third bullet, "consider whether re-authoring per attempt is necessary at all", was evaluated and not taken: an unconfirmed retry re-PUTting its earlier bytes would need the authored record bytes made durable and the whole publish plan pinned across passes, while the drain re-derives its plan from the current gate-passing base each pass. That is a publish-pipeline change, not a retirement one.

Tests

  • crates/engine/tests/write_plane.rs::every_head_block_a_retrying_op_orphaned_leaves_the_inventory — a create whose register-first is refused orphans a distinct head per pass; each leaves the inventory on its own pass, every attempt's CID differs, and the abandonment then owes back the write name on top. Fails without the fix: the retire batch is only [write_name].
  • crates/engine/tests/write_plane.rs::a_publish_that_reached_the_transport_never_retires_its_head — with every endpoint's PUT refused, the head block uploads and nothing is retired. Fails if AllEndpointsFailed is flipped to orphan, which is the destructive direction.
  • crates/engine/src/sync/drain.rs::only_a_publish_that_never_reached_the_transport_orphans_its_head — a unit test over every arm of the predicate, including the two ApiError shapes of a failed upload.
  • crates/engine/tests/write_plane.rs::a_publish_that_never_confirms_dead_letters_once_its_attempt_budget_runs_out (pre-existing) still asserts an empty retire batch — the acked arm is unchanged.
  • crates/contract/tests/contract.rs::every_head_block_a_retrying_publish_orphaned_retires_back_to_the_pre_upload_figure — the live leg: three attempts' head blocks upload and register under one name against the real API/Postgres/Kubo, each charging on its own; retiring only the last leaves the earlier two charged, and retiring the whole set returns the account to its pre-upload figure.
  • Blocks::refuse_register is new test scaffolding: the registration half of register-first refuses while retirement keeps answering, which is what puts a charged head block behind a publish that never reached the transport.

Verification

All run in the worktree, all exit 0:

  • cargo fmt --all --check
  • cargo clippy --workspace --all-targets -- -D warnings
  • cargo check --workspace --all-targets
  • cargo check -p cipherbox-wasm --target wasm32-unknown-unknown --all-targets
  • cargo test --workspace — 32 suites ok, 0 failed
  • pnpm -r --if-present run typecheck
  • pnpm -r --if-present run test — client 111, api 177, web 67, all passing
  • npx eslint .
  • npx markdownlint-cli2 blueprint/engine.md

The first Contract Suite run failed: the leg declared its stand-in head blocks under the dag-cbor codec, which the ingress could not pin because 96 bytes of filler is not valid CBOR. It now uses the same raw address the neighbouring version-set leg uses — the byte accounting the test asserts does not turn on the codec.

Review gates

  • /security-review — one MEDIUM: the first cut retired on AllEndpointsFailed, which would have unpinned the head of a record a lost ack left resolvable, turning a quota leak into an unreadable node. Folded in as the orphaned_head carve-out plus a_publish_that_reached_the_transport_never_retires_its_head, which fails if the arm is flipped back.
  • /simplify — four comment/doc corrections (a cap justification that contradicted retire's own chunking, a doc block re-deriving the blueprint prose, a fourth restatement of the per-attempt fact, and a field doc describing the bug rather than the fix) and a merge of two near-duplicate write_plane tests. All applied.
  • Crypto/privacy pass — confirmed the seal-nonce uniqueness argument (every head is authored with 24 fresh bytes off the injected Entropy seam, prefixed into the sealed blob, so a repeat CID needs a 192-bit nonce collision), that both matches are exhaustive so a new error variant is a compile error rather than a silent true, that no key or plaintext material is retained, and that the retire batch tells the server nothing it did not already learn from the upload header. Three findings applied: the live-set guard above, dropping EmptyHeadCid, and bounding the post-retire drain by the queue's own length. One deferred as engine: retire the settings-record head block a failed publish orphaned #947.

Deferred

#947publish_settings publishes the vault settings head through the same charged ingress and orphans it the same way, but runs outside a drain pass and holds none of its session state. Filed with a depends-on edge both ways: it reuses orphaned_head / record_orphan_head / retire_orphan_heads rather than adding a second mechanism.

Parallel work

#920 is being implemented concurrently in the same registration/retire area and also extends #916. This branch does not touch registered_by, registry_cids, or the registration batch shape; it adds orphaned_head / record_orphan_head / retire_orphan_heads beside them and widens RETIRE_BATCH_MAX to pub(crate). Whichever of the two merges second should re-check crates/engine/src/sync/drain.rs and crates/engine/src/net/retire.rs for a semantic conflict, not just a textual one.

Closes #921

Summary by CodeRabbit

  • Bug Fixes
    • Improved cleanup of orphaned storage blocks after failed record publication attempts.
    • Ensured repeated failed attempts do not leave charged orphaned blocks indefinitely.
    • Preserved potentially live blocks when publication fails after upload.
    • Confirmed abandoned records and their orphaned blocks can be fully retired, restoring quota usage.

@coderabbitai

coderabbitai Bot commented Aug 1, 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: 18 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: c6bc60de-25bf-422f-bb19-6a6bc4a5df3a

📥 Commits

Reviewing files that changed from the base of the PR and between 33655e1 and 275fda6.

📒 Files selected for processing (5)
  • blueprint/engine.md
  • crates/contract/tests/contract.rs
  • crates/engine/src/facade.rs
  • crates/engine/src/sync/drain.rs
  • crates/engine/tests/write_plane.rs

Walkthrough

The engine now tracks head CIDs from eligible failed publishes and retires them after drain processing. Tests cover retry-generated charged heads, registration refusal, live-head preservation, retirement failures, and quota cleanup.

Changes

Orphan head cleanup

Layer / File(s) Summary
Engine orphan state and drain lifecycle
crates/engine/src/facade.rs, crates/engine/src/net/retire.rs, crates/engine/src/sync/drain.rs
Engine stores session-scoped orphan heads. Drain::run processes the queue, then retires queued heads using the crate-visible batch limit.
Failure classification and orphan retirement
crates/engine/src/sync/drain.rs, blueprint/engine.md
Publish failures are classified by storage evidence. Eligible head CIDs are bounded, skipped when held by live records, and retained when retirement fails.
Write-plane and quota validation
crates/engine/tests/write_plane.rs, crates/contract/tests/contract.rs
Tests verify distinct retry heads, registration refusal, orphan retirement, live-head preservation, and quota recovery.

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

Possibly related issues

  • FSM1/cipher-box issue 947: The orphan-head retirement machinery is identified as a dependency for the settings-publish work.

Possibly related PRs

  • FSM1/cipher-box#923: Both changes extend abandonment retirement for uploaded orphaned blocks in sync/drain.rs.
  • FSM1/cipher-box#912: Both changes preserve declared upload CIDs for publish-flow handling.
  • FSM1/cipher-box#886: Both changes modify drain publish handling and write-plane coverage for head-block cleanup.
🚥 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 the engine fix: retiring head blocks orphaned before publish transport.
Linked Issues check ✅ Passed The changes satisfy #921 by tracking and retiring orphaned head CIDs, preserving acknowledged-PUT safety, and adding retry, abandonment, and contract coverage.
Out of Scope Changes check ✅ Passed All changes support #921 through orphan tracking, retirement, documentation, and focused tests; no unrelated code changes are shown.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
✨ 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/921-retire-orphaned-head-blocks

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 force-pushed the fix/921-retire-orphaned-head-blocks branch from d179aca to bba88f4 Compare August 1, 2026 09:53
@FSM1
FSM1 marked this pull request as ready for review August 1, 2026 11:55

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

🤖 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/sync/drain.rs`:
- Around line 1733-1752: Recheck the live held set in retire_orphan_heads
immediately before each destructive retire call, skipping any queued CID whose
head_cid is currently held. Update record_orphan_head’s doc comment to describe
only its enqueue-time live-set check, while preserving the existing orphan queue
cap and enqueue behavior.
🪄 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: 65c5b663-f25a-4b3d-8dca-2e8e3342c0a3

📥 Commits

Reviewing files that changed from the base of the PR and between afb3887 and 33655e1.

📒 Files selected for processing (6)
  • blueprint/engine.md
  • crates/contract/tests/contract.rs
  • crates/engine/src/facade.rs
  • crates/engine/src/net/retire.rs
  • crates/engine/src/sync/drain.rs
  • crates/engine/tests/write_plane.rs

Comment thread crates/engine/src/sync/drain.rs Outdated
@FSM1

FSM1 commented Aug 1, 2026

Copy link
Copy Markdown
Owner Author

Merge-order constraint with #946

Verified by building the pairwise merge: this PR and #946 are each green against main, but their merge does not compile.

#946 deletes pub(crate) const RETIRE_BATCH_MAX from crates/engine/src/net/retire.rs (moved to net/mod.rs as REGISTRY_BATCH_MAX). This PR adds a new consumer of that name in crates/engine/src/sync/drain.rs:49 (use crate::net::retire::{RETIRE_BATCH_MAX, retire};, used in record_orphan_head). Git conflicts only the definition — this import auto-merges silently, giving E0432: unresolved import once the retire.rs conflict is resolved in #946's favour.

There is also a test-fake collision: both branches add refuse_register to the Blocks fake in crates/engine/tests/write_plane.rs with incompatible signatures (bool here vs Vec<u8> there). Not overloadable in Rust; one needs renaming — suggest refuse_register_unavailable on this side, since #946's variant carries the registry error body.

Land #946 first, then rebase this PR. The rebase is small: repoint the import at crate::net::REGISTRY_BATCH_MAX, update its two usages, rename the test helper. #946 also replaces the plain register ack with register_reply(...), which validates the registration payload — this PR's tests were checked against that path and pass.

After a correct hand-resolution the pair is fully green: cargo test --workspace 1151 passed / 0 failed, write_plane 53 tests (the correct union of both branches).

Do not merge the two back-to-back on green checkmarks alone.

FSM1 added 4 commits August 2, 2026 20:28
…sport

Every record publish uploads its head block through the same charged
POST /content/upload a content block goes through, so each attempt creates
its own accountable pin row. A publish that failed before the record
reached the transport left that row charged against an account that could
never reach the block again - and because the drain draws a fresh seal
nonce per pass, every retry orphaned another one. Nothing retired any of
them, for any op kind: #916 taught abandonment to retire the write name
and the content blocks, and folder creates, deletes, renames and moves
have no content blocks at all.

The drain now notes the head CID of any publish that stopped short of the
PUT fan-out - register-first, the floor read, the head-CID echo, a refused
upload aside - and retires the set at the end of the pass that orphaned
it, independent of the op's fate. An op that retries and then succeeds
charged those rows just as surely as one that abandons.

A fan-out that acknowledged nothing is deliberately not one of them:
AllEndpointsFailed says no endpoint acked, not that none stored, and
unpinning the head of a record a lost ack left resolvable is the loss #916
refuses, where leaving the row charged is only a leak.

Closes #921
A refused upload charged no pin row, but a dropped connection or an
unreadable 2xx body may have left one behind: the server committed the
pin and the client never learned the address was live. The retry authors
a fresh head, so nothing revisits that CID and the row is charged
forever.

Only a status answer is the server's own refusal. Transport and decode
failures now orphan their head like every other pre-transport failure,
and a unit test pins each arm - the AllEndpointsFailed arm above all,
where retiring would be loss rather than a leak.
The orphan queue's only consumer physically unpins at refcount zero, so
deciding from the publish error alone put a destructive step one careless
caller away from unpinning a head a resolvable record names. The check
now runs where the damage would be done: a CID the held set carries is
refused outright, whatever the error said.

Also drops EmptyHeadCid from the orphan set - it means nothing was ever
addressed, so there is no CID to retire - and bounds the post-retire
drain by the queue's own length.

The contract leg declared its stand-in head blocks under the dag-cbor
codec, which the ingress could not pin because 96 bytes of filler is not
valid CBOR. It uses the same raw address the neighbouring version-set leg
uses; the byte accounting the test asserts does not turn on the codec.
The doc comment claimed the destructive step checks the live set. It does
not: the check runs at enqueue, in record_orphan_head. State the guard the
code performs and why refusing a live head matters, and drop the claim
about a check that lives nowhere.
@FSM1
FSM1 force-pushed the fix/921-retire-orphaned-head-blocks branch from d376272 to 275fda6 Compare August 2, 2026 18:32
@FSM1
FSM1 marked this pull request as ready for review August 2, 2026 18:34
@FSM1
FSM1 merged commit e9086c3 into main Aug 2, 2026
23 checks passed
@FSM1
FSM1 deleted the fix/921-retire-orphaned-head-blocks branch August 2, 2026 18:38
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.

engine: retire the head block an abandoned op registered, and every one its retries minted

1 participant