Skip to content

fix(graph): bound sealed receipts and refuse unverified cleanup - #4804

Closed
ScriptedAlchemy wants to merge 14 commits into
masterfrom
fix/4616-bounded-sealed-receipts
Closed

ScriptedAlchemy wants to merge 14 commits into
masterfrom
fix/4616-bounded-sealed-receipts

Conversation

@ScriptedAlchemy

@ScriptedAlchemy ScriptedAlchemy commented Nov 8, 2026 •

Copy link
Copy Markdown
Owner

Root cause and scope

Refs #4616 — partial OOM safety and ownership-fail-closed slice, not complete sealed-generation retirement.

The default-branch sealed store has three unrestricted std::fs::read(sealed.json) paths: direct serving, the read-only sealed census, and ordinary receipt loading. A sufficiently large or concurrently growing receipt can allocate unbounded heap even though a receipt is only metadata. Worse, obsolete-format cleanup interprets any absent, unreadable or malformed receipt as "noncurrent" and recursively deletes its directory. Unknown bytes do not establish deletion ownership.

Focused change

  • One capped 64 KiB receipt reader now serves all three call sites. The independent review replaced the hand-rolled reader with the existing tracedecay-private-fs::framed_log::read_bounded authority: a held no-follow, nonblocking file handle with regular-file/type checking, 64 KiB limit, and changed-length refusal. This also rejects symlinked receipts and avoids indefinitely blocking on substituted FIFOs. No dependencies, contracts or build configuration changed.
  • Obsolete-format cleanup now reclaims only a directory whose full, bounded receipt decodes, has a recognized 64-hex physical-namespace directory identity, recognizes the graph form and current receipt version, and explicitly names a graph format older than the current version. Future-format receipts also remain untouched. Invalid/oversized/missing receipts and unrelated files are preserved. A bad sibling does not block an independently valid obsolete generation.
  • Real filesystem Rust regressions verify a normal receipt, JSON-valid oversized metadata, sparse 15 GiB refusal, valid old-format removal alongside malformed/oversized/missing/current/future-format files, a symlink to a valid old-format foreign receipt that cannot authorize deletion, and a 70-generation metadata census with ~15 GiB of sparse containers. Sparse multi-GiB tests are Unix-gated to avoid Windows disk allocation.

Review, validation and boundaries

Current issue-specific head: c99173e0d2790aa7f5495dd3873571b9dfa4c00e; initial three source commits remain in history, followed by two focused review corrections. Exact-head CI: https://github.com/ScriptedAlchemy/tracedecay/actions/runs/42440436843 (queued at update).

Exact one-file source commit 71a2379f1d38e78759f8be1076989518fec2f444 with Unix-only test-import follow-up 7fb3c92655601ab655826355f2a226056fab2dd4 and future-format safety follow-up a1b105f08e6d44eb7e4dd1b7420267500d2d2477, parent master 91c72aba7750a398dd28e83d64bf0c6d6fef4172; branch creation and update used the exact expected old head with force=false. Review fixes: a39ffd186 fixes the failing rustfmt CI gate; c99173e0d replaces the reader with the shared no-follow/nonblocking authority and adds a falsifiable symlinked-receipt deletion regression. Both were published to the exact old head with nonforce fast-forwards, preserving peer changes. No native Bazel/Rust test pass is claimed yet. Run bazel test //crates/tracedecay-graph-db:unit_test //crates/tracedecay-graph-db:graph_db_suite --test_output=errors, CI format/clippy, and native macOS. Confirm a nonzero test count.

This is not a solution for ancestor/root real-directory substitution, path-based recursive deletion TOCTOU, daemon retention scheduling under continuous writers, or proof that tracedecay storage report itself completes on a 70-generation/15-GB production store. Keep #4616 open until all acceptance criteria are proven on master. Do not merge or auto-merge mixed draft #4776 or Kev/decision-model PRs. Release #4763 and production/infra untouched.

Use a 64 KiB capped receipt reader across serving, census and obsolete-format
cleanup. Only verified obsolete-format receipts authorize deletion; missing,
invalid and oversized receipts remain untouched. Add Rust behavioral fixtures
for old-format reclamation and ~70 sparse 220 MiB containers.

Refs #4616
@changeset-bot

changeset-bot Bot commented Nov 8, 2026 •

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 7ab6c2b

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

Only receipts explicitly older than this graph format authorize retirement;
a future format is not a superseded format. Exercise both versions in the
same real filesystem cleanup regression.

Refs #4616
Use the existing no-follow, nonblocking bounded file reader so an
oversized, symlinked or nonregular receipt cannot authorize deletion.
A real filesystem regression proves an old-format symlinked receipt
cannot cause its generation directory to be reclaimed.

Refs #4616
A syntactically valid sealed receipt with graph_format=0 is not a
valid GraphFormatVersion and must not authorize directory removal.
Guard the deletion condition and exercise the preservation path
alongside valid retired-format cleanup.

Refs #4616
The format-reset integration fixture still expected an artifact without
a receipt to be deleted, contradicting this PR's fail-closed ownership
rule. Supply an explicit matching older-format receipt for the artifact
that is reclaimable and separately assert unverified bytes survive.

Retain the current-generation and fresh-store behavior assertions.
No production deletion guard or test budget is weakened.

Refs #4616

@ScriptedAlchemy ScriptedAlchemy left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Reviewed head 86620da5dd6236918916f38660bd96fa805eaf55, including the concurrent format-reset fixture correction, against the PR's narrow scope and #4616 acceptance requirements.

The bounded, no-follow receipt reader and preservation of malformed/zero/future-format receipts are useful partial hardening. The format-reset integration fixture now distinguishes a proven obsolete artifact from an unverified artifact rather than expecting both to be deleted.

I left two inline findings with concrete implementation/test follow-ups: (1) the remaining receipt-validation/pathname-deletion ownership race; (2) the gap between the 70-file sealed-census test and actual bounded storage report coverage. These are remaining #4616 safety/acceptance gaps, not a claim that this PR introduced both problems, and not a request to import work from held draft #4776.

Validation: a disposable Linux filesystem analog reproduced the deletion race; this was NOT repository Rust/Bazel or native macOS execution. Exact-head CI run https://github.com/ScriptedAlchemy/tracedecay/actions/runs/42442665227 was queued when checked. No native test pass, merge readiness, or issue completion is asserted. Required repository checks remain bazel test //crates/tracedecay-graph-db:unit_test //crates/tracedecay-graph-db:graph_db_suite --test_output=errors, plus the eventual reporting-path regression and native macOS coverage; confirm nonzero test execution.

Refs #4616. Keep the issue open until all acceptance requirements are proven on master. This review makes no branch, source, merge, release, infrastructure, or schedule changes.

continue;
}
if receipt.graph_format > 0 && receipt.graph_format < GraphFormatVersion::current().get() {
match std::fs::remove_dir_all(&path) {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

[P1, remaining ownership gap] Bind deletion to the directory whose receipt was validated. load_sealed_store_receipt(&path) validates one opened file, but this remove_dir_all(&path) resolves the directory pathname again. Replacing the sealed-root/ancestor pathname after receipt validation can therefore redirect deletion into a foreign directory with the same child name. O_NOFOLLOW on the final sealed.json component and the earlier DirEntry::file_type() do not bind this later operation to the validated directory.

I reproduced that sequence in a disposable Linux filesystem analog: read the genuine receipt through O_NOFOLLOW|O_NONBLOCK, rename the owned sealed root, replace its pathname with a symlink to a foreign root, then recursively remove the previously captured child pathname. The foreign child disappeared while the original owned child survived. This is an OS-level reproduction, not a Rust/Bazel test.

Reuse the existing directory-capability authority, anchored to the store owner's held identity, for receipt reads and recursive removal; preserve/refuse substituted children rather than relying on another pathname precheck. Add a coordinated native regression that swaps the root/ancestor and a real child directory after validation, proves foreign sentinels survive, and separately proves an ordinary verified obsolete generation is reclaimed. This is the pre-existing race acknowledged in the PR body, but it still prevents counting this path as #4616 ownership-safe cleanup.

.set_len(FILE_BYTES)
.unwrap();
}
let census = census_sealed_store(&database, &BTreeSet::from(["g69".to_owned()])).unwrap();

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Coverage follow-up: this does not exercise the failing storage report journey. The fixture gives the sealed-census helper 70 small receipts, one sparse container per directory, and a manually supplied head set. It does not construct the issue's ~15 GB code-index-v1 tree or invoke the report collector/CLI. The pre-change metadata census can also handle these sparse containers without reading their bodies, so this test alone cannot catch an OOM regression in the actual report path.

There is also no work/entry budget in this census's directory_bytes: its pending-directory vector and hard-link identity set grow with the traversed tree, independently of the new per-receipt 64 KiB cap. That is a remaining bound to address, not evidence that this particular 70-file fixture OOMs.

Keep this focused census test, but add a behavioral test through the actual storage-report production entry point with both sealed generations and code-index data. Cover many small/hard-linked entries as well as large sparse payloads, require correct totals or explicitly partial coverage when the shared traversal budget is exhausted, and verify the selected Bazel test actually runs. Until then, retain the PR's partial-scope wording and do not treat this test as acceptance item 5.

Match two exact hosted Bazel rustfmt diagnostics while retaining
zero-format refusal and fail-closed directory preservation coverage.

Refs #4616
Directory-byte census followed directory symlinks, charging unrelated
payloads to sealed generations and allowing cycles or dangling links to
break large-store reporting. Inspect each entry without following its final
component; charge a symlink's own metadata, not its target.

Extend the 70-generation sparse filesystem regression with an external
20 GiB target and dangling link. The pre-fix census follows or fails on
these links, while the corrected census completes without counting them.

Refs #4616

Copy link
Copy Markdown
Owner Author

Refs #4616 — published fec6972 to this existing issue-specific PR via exact expected head 236e5952508b37068b9e72357f266bc22e44be95 and force=false; independently verified the remote branch and PR at fec6972. The one-file diff is +18/-1 (crates/tracedecay-graph-db/src/sealed_store.rs).

Root cause: directory_bytes used DirEntry::metadata, which follows symlinks. A symlink inside one sealed generation could therefore make the census descend into an unrelated directory and charge its payload as sealed graph bytes; a dangling symlink aborted the census entirely; a link back to an ancestor could produce repeated traversal. The helper now uses symlink_metadata so directory links are measured as links, not followed.

Behavioral regression: the existing 70-generation (~15 GiB sparse) census fixture now includes a symlink to a foreign directory with a 20 GiB sparse file and a dangling link. It requires the exact 1-head/69-superseded classification and a superseded-byte bound that excludes the foreign 20 GiB payload. The former code fails that scenario; the changed code still measures ordinary file/hard-link bytes. No additional dependencies or CI config changes.

Validation: GitHub compare confirms a one-commit fast-forward touching only the graph sealed-store source. No local Bazel/native macOS execution; no Cargo. Fresh exact-head CI #42469034128 is pending. The previous head's hosted Linux fleet did run and pass //crates/tracedecay-graph-db:unit_test and :graph_db_suite, but those passes are not passes for fec6972; check nonzero test execution on this exact head before considering merge. This repair is partial OOM/report safety; outstanding pathname-ancestry deletion TOCTOU and unbounded legitimate-entry traversal are not solved. Do not close #4616 or merge held mixed draft #4776.

@devin-ai-integration devin-ai-integration Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Note

Newer findings are available below. Devin Review posted a newer report on this PR, in addition to the findings presented here.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no bugs or issues to report.

Devin Review

…links

The sealed.checked proof-hint reader still used an unrestricted
read_to_string even after receipt reads were bounded. A growing or sparse
marker could OOM ordinary serving or staging-release checks. Replace this
with the shared bounded no-follow regular-file reader; invalid lengths
are cache misses and require the normal full proof.

Marker persistence also used std::fs::write, which could truncate an
unrelated file through a substituted symlink. Stage and atomically replace
using the existing private-fs publisher instead. Filesystem regressions
cover valid hints, oversized metadata, a sparse 15 GiB hint, and a foreign
symlink that cannot supply evidence or be overwritten.

Refs #4616

Copy link
Copy Markdown
Owner Author

Published scoped reviewer follow-up 7a1a7cc: bounded no-follow sealed.checked proof-hint reads and atomic replacement instead of std::fs::write to an attacker-substitutable final symlink. Native filesystem regression checks valid/oversized/15 GiB sparse markers and foreign symlink preservation. Expected-head nonforce publish verified. CI run 42470659905 queued; native Bazel/macOS unverified. Refs #4616. Existing unresolved ancestor/root directory deletion and production-report coverage reviews remain open; this commit does not claim to address those.

Replace pathname-based obsolete-format cleanup with the shared
capability-relative, no-follow recursive removal authority. Bounded receipts
are opened and validated beneath held generation directory capabilities;
a substituted root or child name after validation cannot redirect deletion.

Preserve invalid, absent or unknown receipts, and reject receipt names that
traverse outside the held directory. Add filesystem regressions for
replacement root symlinks and replacement real child directories, requiring
foreign sentinels to survive while validated old directories are retired.

Refs #4616
devin-ai-integration[bot]

This comment was marked as resolved.

Copy link
Copy Markdown
Owner Author

Refs #4616 — published 416043f on this issue-specific PR by exact expected-old-head 7a1a7ccd4aa6e7b2a7e6c34132e3bc17ec0298f4, force=false fast-forward. Verified PR/branch at the new head; GitHub compare shows exactly three changed files: tracedecay-graph-db/src/sealed_store.rs plus existing tracedecay-private-fs capability/reader authorities.

Root cause addressed (obsolete-format cleanup path only): the prior read sealed.json then std::fs::remove_dir_all(path) resolved the directory pathname twice. A root/child replacement between those operations could redirect deletion into foreign content. Obsolete-format census now holds one no-follow root and child directory capability, reads the 64-KiB receipt on the held child with a no-follow nonblocking handle and the shared bounded-file validator, validates the old-format namespace/receipt policy, and recursively removes through that same opened child. Unknown/oversized/symlink receipts remain preserved; deleting verified obsolete bytes fails as a typed error rather than claiming success.

Two new Unix filesystem regressions coordinate (1) moving the validated root and replacing its pathname with a symlink to a foreign root and (2) moving the validated child and putting an unrelated real directory in the old slot. They require both foreign sentinels to survive and the validated old artifact to be removed. Another regression rejects receipt filenames containing parent traversal.

Scope/remaining safety: this fixes the post-validation path re-resolution in obsolete-format cleanup, not the separate ordinary remove_sealed_directory_checked retirement path or every possible pre-open ancestor substitution. Those and end-to-end N>3 sealed retirement require separate ownership evidence and native acceptance; leave the prior P1 review thread open until all destructive paths are covered. No decisions/changes from mixed draft #4776 were moved here.

Validation: source and exact diff reviewed; local checkout/Bazel/native macOS execution unavailable. No Rust test pass or merge readiness is claimed. Exact-head CI run #42479619425 was pending when checked. Run bazel test //crates/tracedecay-private-fs:unit_test //crates/tracedecay-graph-db:unit_test //crates/tracedecay-graph-db:graph_db_suite --test_output=errors (confirm nonzero new regressions) and native macOS. Keep #4616 open; no merge, release/infra, tags or other branches touched.

Replace remaining pathname-based recursive sealed cleanup with no-follow
directory capabilities. Durable replay retirement now requires a bounded
receipt proving exact namespace, projection, generation, format, and
when available the adopted digest. Refuse unknown ownership for retry.

Add behavioral Rust regressions for mismatched receipt/digest, verified
idempotent reclaim, child real-directory replacement after validation,
and a substituted sealed-root symlink. Move Unix imports to module scope.

Partial ownership hardening: the ambient store parent still needs a
stable owner-identity anchor against pre-acquisition substitution.

Refs #4616

Copy link
Copy Markdown
Owner Author

Refs #4616 — published 8f7c8128 directly to this issue-specific PR through an exact expected-old-head 416043f8b05c9849cfa1e5a79e639426bfb98672, force=false fast-forward; verified the remote PR head and a one-commit, two-path diff.

Source fix: ordinary accepted-replay sealed-generation retirement (both installed-reader and durable tombstone retries) formerly called pathname-based remove_dir_all. It now opens the exact generation through a held no-follow directory capability, reads a bounded sealed.json from that same child, and requires recognizable form/version plus matching namespace/projection/generation/physical identity and, when available, the adopted recovered digest before deletion. Unreadable or mismatched receipts cause a typed retry refusal, not successful retirement of unverified bytes. Best-effort staging/spill/failed-build recursive cleanup also uses the held-directory no-follow unlink authority, rather than following a separately resolved deletion pathname.

New behavioral Rust regressions: a valid retired generation is physically removed and an idempotent retry succeeds; a wrong-generation receipt and wrong recovered digest preserve a sentinel; a real child directory substituted after receipt validation preserves the replacement and deletes only the held original; a sealed-root symlink substituted before opening cannot redirect the new helpers into a foreign directory. The reviewed inline Unix imports are now module-scoped. No dependencies or CI policy changed.

Limits / validation: this is partial ownership hardening. The parent of the sealed root still enters through an ambient path; a replaced symlinked ancestor or foreign real directory before capability acquisition is not bound to the original owner and needs a separate owner-identity repair. Full automatic N>3 retired-generation acceptance is not demonstrated by this commit. No repository Bazel/macOS pass is claimed: the working container has no checkout/Bazel, and no Cargo was invoked. Exact-head CI #42484407257 is queued. Required targets: bazel test //crates/tracedecay-graph-db:unit_test //crates/tracedecay-private-fs:unit_test --test_output=errors with nonzero regression counts, plus native macOS and other applicable CI/review. Keep issue open, and keep mixed Kev/decision-model draft #4776 unmerged.

devin-ai-integration[bot]

This comment was marked as resolved.

A no-follow cleanup failure previously fell through to create_dir_all,
which accepts a foreign staging symlink as an existing directory. The
subsequent container builder then wrote through the symlink target.

Propagate cleanup refusal, preserve a genuine abandoned directory's
rebuild path, and use single-directory creation so an existing staged
entry cannot be accepted by recursive creation. Add a Unix regression
that places a foreign staging symlink and proves no container or receipt
is written to the target.

Refs #4616
After checking that a foreign staging symlink is preserved and no
foreign payload is written, remove only the link and build the same
generation successfully. This proves the regression is not satisfied
by unconditionally refusing all sealed builds.

Refs #4616

Copy link
Copy Markdown
Owner Author

Refs #4616 — published and independently checked two focused commits directly to this issue-specific PR: b0fa1675 and 7ab6c2b9. Both were fast-forwarded with exact expected-old-head, force=false; remote PR/compare verifies the current head 7ab6c2b, ahead by two, with only crates/tracedecay-graph-db/src/sealed_store.rs modified (+78/-2). No peer or mixed branch was touched.

Root cause: In build_or_open_sealed_store, remove_sealed_directory(&staging) logged but ignored failure. It was followed by create_dir_all(&staging), which treats a symlink to an existing foreign directory as success, then path-based container/receipt writes followed the symlink. Existing capability cleanup could refuse a symlink but the caller still built into it.

Repair: Create the root if absent, require the existing no-follow child-removal authority to succeed (no suppressed symlink refusal), then use nonrecursive create_dir for a new staging entry. A pre-existing symlink, or an entry substituted before creation, is refused rather than adopted. The Unix Rust behavioral regression plants a foreign staging symlink with a sentinel and requires refusal, preservation of the symlink/sentinel, no container or receipt written into the foreign directory, and no installed generation. It then removes only the link and proves the same manifest seals normally with the expected digest, preventing a vacuous always-refuse test. Existing crash-image regeneration remains covered by its separate build test.

Validation status: independent source/diff review and exact-head remote verification; not a Bazel/native macOS test pass. This environment has no repository checkout or Bazel; no Cargo was invoked. Run bazel test //crates/tracedecay-graph-db:unit_test //crates/tracedecay-graph-db:graph_db_suite --test_output=errors with nonzero execution of the new test and review exact-head CI #42490063804, now queued.

Limits: this addresses the pre-existing staging symlink case identified by review. It does not anchor the store-parent identity before opening, nor prevent a hostile rename/replacement after staging creation while the path-based container writer runs. Those require separate ownership-safe staging I/O design and native regressions. Nor does it prove N>3 automatic sealed retirement, text-artifact isolation, sustained writer progress, Doctor guidance or full report memory acceptance on master. Keep #4616 OPEN and #4776 DRAFT/unmerged. No release #4763, infrastructure, tags, credentials, CI-gate or schedules changed.

Copy link
Copy Markdown
Owner Author

Refs #4616 — superseded by clean-history PR #4819, commit c8a1effe. #4819 preserves the exact four source/test blobs from this PR and adds the narrowly related retry-state implementation/regression from #4818, so the strict collector's typed error/receipt behavior is tested together. The previous branch/commits remain unchanged; this PR's noncompliant immutable commit header prevented its repository gate from ever passing without history replacement. The new one-commit branch was built from exact master and advanced with expected-old-head force=false. Closing this superseded PR without merging reduces duplicate CI lanes; #4819 is open for Bazel/CI/review and is not yet considered passing.

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