Repository navigation
seal write safety: colliding seal paths are not refused and the spec hash is not re-checked after the write (REA 6.6.0 patterns) #10457
Description
REA 6.6.0 (release) shipped three write-safety fixes: "Reject colliding extraction paths before writing", "Recheck staged output identity after its final write", and keeping completed Evidence through cancellation. Its 6.7.0 (release) duplicate-member fix is already #10421 / draft #10442. This issue checks the other three against the seal pipeline: t27c seal --save (run_seal, bootstrap/src/main.rs) and t27c frontier --reseal (run_frontier, bootstrap/src/service.rs).
Result: two gaps, one pattern present. All evidence below comes from the Railway lab, t27c 0.5.2 at master 89254d2, using only t27c commands and .t27 fixtures.
The rule is written as specs/verified/seal_write.t27 (module VerifiedSealWrite), with 14 tests and negative controls. Three weakened mutants (destination check, post-write check, rename protocol) each fail 3, 3 and 1 tests.
Gap 1: colliding seal paths are not refused before any write
seal_file_path derives .trinity/seals/<parentdir>_<module>.json, so two specs can derive one file, and --save also matches an existing file case-insensitively. seal --save writes over whatever is there. specs/verified/seal_identity.t27 (remint_keeps, file_is_its_own) already records the collision and makes frontier write the other seal back, but seal --save itself never refuses.
Fixtures: specs/a/same/m.t27 and specs/b/same/m.t27, both module SameName;.
$ t27c seal specs/a/same/m.t27 --save
tests 1/1 pass
Seal saved to .trinity/seals/same_SameName.json rc=0 spec_path specs/a/same/m.t27
$ t27c seal specs/b/same/m.t27 --save
tests 1/1 pass
Seal saved to .trinity/seals/same_SameName.json rc=0 spec_path specs/b/same/m.t27
$ t27c seal specs/a/same/m.t27 --verify
spec_hash: MISMATCH (saved=sha256:775339...6fe6, current=sha256:21d796...0d63)
gen_hash_zig / verilog / c / rust: MISMATCH rc=1
- Case variant:
module Upper;inspecs/c/case/m.t27andmodule upper;inspecs/d/case/m.t27. Saving D writes into C'scase_Upper.json, andseal --verifyof C then reports VERIFICATION FAILED. - Corpus: 2198 specs have a module line, and 8 derived names collide case-insensitively:
a_b_c_spec,coder_igla-coder-pipeline,openxc7-synth_quantum_bridge_top,openxc7-synth_trinity_top(about 35 specs),rtl-root_trinity_top(11),scripts_port-scripts,src_port::trios::crates::trios_igla_trainer::src,tri_port::trinity::src::tri(5). - frontier --reseal: if the run is not cut, frontier prints "kept the old seal specs/b/same/m.t27" and A still verifies. If the frontier parent is killed (SIGKILL) while its child
t27c seal specs/b/same/m.t27 --saveruns, the file is left holdingspec_path specs/b/same/m.t27, and A reports "VERIFICATION FAILED". The copy needed to restore the file was held only in the parent's memory.
Gap 2: the hash is not re-checked after the final write
compute_seal_hashes reads and hashes the spec once. test_report::run then reads the file again to run the tests, and fs::write writes the seal. Nothing re-hashes the spec or reads the seal back after the write.
Fixtures:
old.t27:module Flip;,which()returns 1, testwhich_is_twofails.new.t27: returns 2, the test passes.
Steps: copy old into specs/r/flip.t27. Swap in new atomically 0 to 4 ms after seal --save starts. Once the save finishes, swap old back.
delay=0..0.004 save_rc=0 tests 1/1 pass (14 of 18 trials; at 5 ms and later: "refusing to seal ... 1 of 1 test(s) FAIL")
$ grep spec_hash .trinity/seals/r_Flip.json
"spec_hash": "sha256:eef95aeac1a1bcf4e11d4ffc321ebe6bc9fff90d4f04951ff092195e9847b243" <- the FAILING bytes
$ t27c seal specs/r/flip.t27 --verify
all hashes MATCH verify_rc=0
$ t27c test-report specs/r/flip.t27
- which_is_two (failed, not counted)
$ t27c frontier --list
frontier: reused 1 of 1 (1000 permille)
The seal certifies a passing run for bytes that fail, and frontier reuses it.
- Non-atomic swap: with
cpinstead ofmvat 5 ms, the save sealed "tests 0/0 pass", and verify reported MATCH. - Edit during the test run: a 0.3 s sleep, then a comment-only edit.
seal --saveprinted "Seal saved" and exited 0, and an immediateseal --verifyreported spec_hash MISMATCH, rc=1. The seal was already stale when written, and the writer never noticed.
Pattern 3: completed seals survive a cut run (present, with one caveat)
Steps: run timeout -s <SIG> 6 t27c frontier --reseal over six never-sealed specs (specs/batch/s1..s6.t27).
| signal | finished seals | seal --verify |
rest |
|---|---|---|---|
| SIGINT | 4 | all hashes MATCH | no seal file, no partial file |
| SIGTERM | 2 | all hashes MATCH | no seal file, no partial file |
| SIGKILL | 3 | all hashes MATCH | no seal file, no partial file |
Caveat: the seal in flight is not protected. The child writes in place with a non-atomic fs::write, and the restore happens only if the parent survives, which is the gap 1 frontier case above. A torn seal file is possible from reading the code but was not reproduced.
The rule (specs/verified/seal_write.t27)
dest_check: the destination is free only when no file answers to the name (case-insensitively), or the file there records exactly thisspec_path. An unreadable owner fails closed.post_write_check: after the seal is written to a staging file, three things must hold, or the seal is refused:- the spec re-hashes to the recorded
spec_hash; - the tests ran on the bytes that hash names;
- the staged file reads back as written.
- the spec re-hashes to the recorded
after_cut/proto_cut_safe: the checked staging file replaces the old seal with one rename. A cut at any point leaves either the old seal or the new checked one.seal --save(in place) andfrontier --reseal(write, then restore from memory) both fail this today.
Closing the gaps
Closing these needs Rust changes in run_seal and run_frontier. They are in a separate draft PR that waits for the owner's owner-approved-foreign label.
User Scenarios
- Given two specs at different paths (
specs/a/same/m.t27andspecs/b/same/m.t27) that derive the same seal file name case-insensitively, when runningt27c seal --saveon the first then the second, then the second save MUST be refused before any write occurs and exit non-zero. - Given a spec file that is atomically replaced with different content during
t27c seal --saveexecution, when the write completes, then the seal MUST be refused because the re-checked spec hash no longer matches the recordedspec_hash. - Given a
t27c frontier --resealrun processing multiple specs, when the parent process is killed by SIGKILL while a childt27c seal --saveis writing, then the pre-existing seal file for that spec MUST remain intact and verifiable witht27c seal --verify.
Requirements
- FR-001:
t27c seal --saveMUST refuse to write if the destination seal file exists and records a differentspec_path(case-insensitive collision check on the derived seal name). - FR-002:
t27c seal --saveMUST re-hash the spec file after the final write and refuse the seal if the hash differs from the recordedspec_hash. - FR-003:
t27c seal --saveMUST write to a staging file and atomically rename it to the destination, ensuring a torn write leaves either the old seal or the new verified seal. - FR-004:
t27c frontier --resealMUST protect in-flight seal writes by using the same staging-and-rename protocol, so that a killed parent does not leave a corrupted seal file. - FR-005: The post-write verification MUST confirm that the tests ran on the bytes that hash to the recorded
spec_hashand that the staged file reads back as written.
Success Criteria
t27c test specs/verified/seal_write.t27passes all 14 tests (exit 0)t27c seal specs/a/same/m.t27 --savethent27c seal specs/b/same/m.t27 --saveresults in the second command exiting non-zerot27c seal specs/c/case/m.t27 --savethent27c seal specs/d/case/m.t27 --saveresults in the second command exiting non-zero- Atomic swap of spec content during
t27c seal --savecauses the command to exit non-zero and no seal is written t27c frontier --resealinterrupted by SIGKILL leaves all pre-existing seals verifiable viat27c seal --verify(exit 0 for each)
Boundary
- specs/verified/seal_write.t27
- specs/verified/seal_identity.t27
- bootstrap/src/main.rs
- bootstrap/src/service.rs
- .trinity/seals/same_SameName.json
- .trinity/seals/r_Flip.json
Activity
github-actions commentedon Oct 18, 2026
Closed by the issue-shape gate (specs/policy/issue_shape.t27, #7498): an issue must have the template's sections. Edit the body; an edit that passes reopens it.
- specs/x.t27); without it the Queen reserves nothing and no bee can take the issueFR-001: ... MUST ...exit 0, or a backticked commanddocs(verified): seal_write.t27 carries why the seal lookup folds case…
gHashTag commentedon Oct 18, 2026
Status, and two related write-safety gaps found on the lab while closing this one.
PRs
specs/verified/seal_write.t27, the rule as t27 (14 tests, negative controls included, sealed 14/14 with 0 vacuous). Auto-merge is on.t27c seal --savecalls the spec's generated functions (dest_checkbefore the write,post_write_checkafter it, then one rename). It edits existing Rust, so it waits for the owner'sowner-approved-foreignlabel. Under the foreign-budget shrink rule it removes more hand-written Rust than it adds (+19/-20).Owner's acceptance criteria (2026-10-18)
dest_check, lab rows 1-2 in fix(seal): refuse a seal file another spec owns, re-check the hash after the write, land it by rename #10464.post_write_check, lab rows 3-4.Follow-up A:
seal --verifynever reads the committed gen file. On the lab, with t27c 89254d2,bootstrap/gen/rust/verified/seal_write.rswas cut to its first 200 bytes.t27c seal specs/verified/seal_write.t27 --verifystill printedall hashes MATCHwith exit 0.gen_hash_rustis compared against a fresh in-memory generation, never against the file on disk. A committed gen file written through a shell redirect, then hand-edited or truncated, is therefore never caught by the seal. Candidate rule forseal_write.t27: when the committed gen file exists, its bytes must hash to the recordedgen_hash_*.Follow-up B:
t27c compileoverwrites a file next to the spec without being asked. On the lab,specs/x/k.zigheld hand-written content.t27c compile specs/x/k.t27replaced it, printingwrote specs/x/k.zigwith exit 0.--backend verilogdid the same tospecs/x/k.v. The lab binary has noptxbackend (zig, verilog, c, ml only), so.ptxwas not tested, but it follows the same<spec>.<ext>path rule. Candidate rule: refuse to replace an existing output that no generator header marks as generated, unless-onames it.Both are separate from this issue's seal path. Each needs its own spec first, then the same draft-PR route.
feat(verified): seal_write.t27 -- what one seal write must guarantee …
gHashTag commentedon Oct 18, 2026
Follow-ups A and B (from #10457 (comment)) are reproduced on the Railway lab with t27c and
.t27fixtures only.specs/verified/gen_file_check.t27(A, 12 tests) andspecs/compiler/compile_output.t27(B, 10 tests), both with negative controls and sealed without--force. t27b passes both.owner-approved-foreign; do not merge it without that label.seal --verifynow compares each committedbootstrap/gen/rust,gen/rust,gen/candgen/verilogfile with what its generator emits today.compilerefuses to overwrite a hand-written output or the input spec unless-onames it.main.rsis +40/-60.One finding changed rule A.
sealhashes the unresolved source, whilegen-rustandgen-cresolveusefirst. Untouched files can therefore differ from the recordedgen_hash_*(bench_boards, die_binding, claw/gates) while still equalling their generator's output. The rule compares against the generator, not the seal.Tree-wide, with #10481 built: 15 specs (16 files) fail the new check, and every file inspected was genuinely stale. Examples:
gen/c/numeric/gf16.candgen/verilog/numeric/gf16.vdate from #408. The full list is in #10481. Before it lands, those files need regenerating, or the check should start in warn mode.spec: seal --verify owes the committed gen file; compile refuses a ha…