Repository navigation
Close the SCM init TOCTOU with a modeled create-only write - #10026
Conversation
A working 'gunbc scm' CLI existed and was lost with an uncommitted /tmp worktree, along with the plan doc describing it. This commits the survey so the rebuild does not start by re-reading every scm module, and so the next context loss costs nothing. The useful finding from re-surveying: the gap is smaller and more specific than 'build a CLI'. gunbc.scm.render ALREADY reaches CliWireResponse via scm_log_cli_response and scm_status_cli_response -- they simply have no consumer outside dag/test/claim/scm/scm_render_witness_test.dag, which is the unwired-renderer state gunbc.cli_dispatch_surface already records. It also states why the corpus's idiomatic instrument shape cannot substitute for the host binding: 'fn check(...) -> ProcessExit' can only emit text on FAILURE (exit_failure's reason), and log/status must print on success. That is the argument for the main.rs work rather than a workaround, and it is the thing I would otherwise have had to re-derive. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wy8wRfzTK2kvFzj9nAEbyT
gunbc.scm.render already produces CliWireResponse (scm_log_cli_response,
scm_status_cli_response) and nothing outside its witness test consumes it -- the answer is
computed and discarded, which is the unwired-renderer state gunbc.cli_dispatch_surface
records. This adds the two host-side pieces that binding needs.
classify_cli_wire sits beside classify_exit as the single authority for reading the
variant shape, so the driver seam cannot fork it. A missing or wrongly-shaped field is
NotCliWire rather than a default: defaulting bytes to "" would print nothing and exit 0,
which is a fabricated plausible output.
cli_wire_outcome is the total, pure map from that class to what the host does -- bytes,
status, message. Pure for the reason exit_status_for records about itself: the inlined
version of that decision dropped a case and reported success for every failure. It lives
in the LIB, not beside the driver, because main.rs is a BIN target and the rust-unit-tests
job runs 'cargo test -p v1-compiler --lib' -- a test written next to the driver is compiled
by clippy and executed by nobody. exit_status_for_class is shared by both paths so the wire
path and the plain ProcessExit path cannot disagree about what a verdict means.
NotCliWire yields None rather than a failure, so the driver falls through to classify_exit
and every existing ProcessExit entry behaves exactly as before; a test pins that.
5 executing witnesses: bytes written with the response's own exit, a rendered answer that
still fails, the renderer's refusal not reading as an empty success, the fall-through, and
the inherited ExitFailure{code:0} refusal.
NOT YET WIRED into run_verb -- that is the next commit, and until it lands this reader has
no production consumer.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wy8wRfzTK2kvFzj9nAEbyT
gunbc.scm.render has produced CliWireResponse since it was written, and outside its
witness test nothing consumed it. A function returning one hit classify_exit's
NotProcessExit arm and the run refused with 'wrap the result in ExitSuccess /
ExitFailure', so every gunbc scm answer was computed and discarded -- the unwired-renderer
state gunbc.cli_dispatch_surface records.
run_verb now tries cli_wire_outcome first and falls through to classify_exit unchanged for
every other value. Binding it in the OUTCOME SEAM rather than under a new 'scm' subcommand
keeps dispatch peripheral (§3): one binding serves every wire-returning entry instead of
the host growing an arm per verb.
dag/gunbc/scm/cli.dag is the entry the host invokes -- scm_log and scm_status, composing
the read side onto a declared plain-terminal capability. The capability is declared, not
detected: a host that learns to report a real one passes it in.
PROVEN BY EXECUTION, not by typecheck:
gunbc run --entry dag/gunbc/scm/cli.dag --function scm_log --arg path=/tmp/no-such-repo
cannot read repository at /tmp/no-such-repo
No such file or directory (os error 2)
repository unavailable
EXIT=1
That is the renderer's own document on stdout AND a nonzero exit -- the
'printable response carrying a failing exit' case, which is the one an absorbing
implementation would have turned into either silence or a spurious 0.
Also dissolves a fork I had just created: main.rs's exit_status_for carried its own copy of
the verdict map, and once the wire path needed the same decision that copy was two places
free to drift. The body now lives in cli_run::exit_status_for_class, where an executing
test can reach it -- rust-unit-tests runs 'cargo test --lib', and main.rs is a bin whose
tests are compiled by clippy and run by nobody.
clippy --all-targets -D warnings: clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wy8wRfzTK2kvFzj9nAEbyT
Receipts for the three executed cases, including the load-bearing one (bytes printed AND a nonzero exit), so the next session does not re-derive them. States the rung honestly rather than implying the e2e is covered: the five cli_wire_outcome tests execute in CI, and the end-to-end runs are a MANUAL receipt. An integration test would live in src/v1/tests/, which clippy compiles and no CI step runs; a .dag witness cannot substitute because scm_log reads a file and the SCM witnesses are SubstrateInputsOnly. So the e2e path is mitigatable and its next-rung trigger is a CI step that runs an integration target -- not another test file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wy8wRfzTK2kvFzj9nAEbyT
gunbc.scm.render is already witnessed against PlainTerminal and an ASCII no-colour capability. Nothing held gunbc.scm.cli to passing THOSE: setting color: true on plain_terminal_capability would have turned no claim red, which is the inert-check shape DESIGN calls worse than absent. The composition is split from the read -- scm_log_response / scm_status_response take the read's RESULT and are pure, while scm_log / scm_status supply it from a path. That split is what makes the claims authorable at all: the verbs read a file and this witness family is SubstrateInputsOnly, so a claim can never call them. Five enrolled claims: the unavailable-repository bytes (pinned to the same expected string the render-level witness uses, so an equal answer is evidence the PLAIN target and capability were passed, not merely that something was), its failing exit, the empty-log answer with a succeeding exit, the same for status, and the capability row itself so a reader editing it learns it is a contract. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wy8wRfzTK2kvFzj9nAEbyT
gunbc run --entry dag/gunbc/scm/cli.dag --function scm_init --arg path=/tmp/demo-repo.json initialized repository at /tmp/demo-repo.json rc=0, file created ... --function scm_log --arg path=/tmp/demo-repo.json no commits yet init writes the document, log reads it back. That settles the open question the plan doc recorded: a host WRITE is permitted from gunbc run, so the remaining write verbs are a modeling question rather than a permissions one. render.dag gains the write-answer renderer. A write verb answers with what the write DID, and its four outcomes are not one line with a flag -- each names a different failure and a different next action. RepositoryWriteByteCountUnrepresentable EXITS FAILURE even though bytes reached the disk: a write whose reported size is not a magnitude has not been confirmed, and reporting success for it is the fabricated plausible output §5 forbids. TWO LOSSES NAMED IN THE SOURCE RATHER THAN HIDDEN: - The codec refusal carries a typed RepositoryEncodeRefusal with six arms (uncontained target, root not in store, checkout not in commits, duplicated reference, reference outside allocator, parent not in commits) and this renderer does not decompose it, so an operator learns THAT encoding refused and not WHICH invariant failed. Rendering it needs a per-arm function over ObjectId and RepositoryCommitRef. - init does NOT refuse an existing repository. save_repository writes unconditionally, so init over a populated path overwrites it. Refusing needs a read-before-write that is not expressible as one outcome here. That is why this verb is not offered as a safe default. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wy8wRfzTK2kvFzj9nAEbyT
Settles by execution what was open: a host write IS permitted from gunbc run, content enters via store_node, a Node is built with node_synthetic (the test modules' 'atom' is a LOCAL helper, not an authority), and mint_repository_commit requires store_contains(root), so add must precede commit -- and because identity is content-derived, commit can re-derive an added object's ObjectId by rebuilding the same node. Names the design step rather than sketching it: add composes store_node's 2 arms with save_repository's 4, and commit composes mint's 4 with the same save. A renderer taking two outcome values would encode 'which one failed' positionally and the arms multiply. One modeled ScmWriteOutcome is the increment's real content. I deliberately did not improvise it into cli.dag at the end of a long session, because a write-verb outcome invented at a call site is the anemic modeling this repository keeps paying for. Also records that 'add' has no staging authority to persist to -- repository_status takes pending as a PARAMETER because what is staged is not a fact the document carries -- so a literal stage-now-commit-later add needs a staging authority to exist first. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wy8wRfzTK2kvFzj9nAEbyT
…ake it fail-closed
Review 58044 was right and this was the worst thing in the PR. save_repository writes
unconditionally -- correctly, it is persistence and not policy -- so a verb offering
initialization has to decide for itself whether there is anything at the path it would
destroy. The previous revision skipped that decision, called save directly, and NAMED the
overwrite in a comment. DESIGN §5's review bar is that a diff landing a non-fail-closed
failure arm is a hard reject regardless of what else it delivers; an annotation is not a
refusal.
gunbc.scm.init owns the decision, because whether there is anything to destroy is a fact
about the repository and deciding it in the CLI module would put policy in the realization
layer. ScmInitOutcome has three arms and no 'initialized: Bool' beside a save outcome -- a
value whose flag disagreed with its shape would have no spelling -- and the two refusals are
distinct because the operator's next action differs: a path that already IS a repository is
not the same as a path holding someone else's bytes.
MEASURED, including the case that proves the destruction is actually closed:
fresh path -> initialized repository at /tmp/d2.json
same path again -> refusing to initialize /tmp/d2.json
a repository is already there, and init would replace its whole history
foreign file -> refusing to initialize /tmp/foreign.json
a file is already there that is not a repository, and init would destroy it
foreign file after-> 'not a repo' (intact -- read back, not assumed)
THE RESIDUAL IS STATED IN THE MODULE RATHER THAN IMPLIED. RepositoryFileUnreadable fuses
'absent' with 'present but unreadable' and carries the host's error STRING. This module
refuses to branch on that text, because deciding a destructive question by matching an errno
spelling is the stringly reasoning the substrate exists to remove. So the proceed arm is
'unreadable', not 'absent'. Closing it needs a modeled path-existence observation distinct
from a read failure, which does not exist yet and is the module's next-rung trigger.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wy8wRfzTK2kvFzj9nAEbyT
…he wire host Rust Three blocking findings from review 58060 on #9864. 1. `init` was fail-open on `RepositoryFileUnreadable`. Proceeding there is a guess about the host's permission model, not an observation -- a file can be unreadable and perfectly truncatable, so the proceed arm could destroy the bytes it existed to protect. The residual was NAMED in the module header, and a named residual does not refuse. `initialize_repository` now takes a DIRECTORY and a NAME and routes through `extdeps.filesystem.filesystem_io` `filesystem_file_observation`: absence is established from a listing that succeeded and did not name the entry, and only that arm reaches `save_repository`. Present bytes are classified (repository vs foreign) for the operator's benefit but both refuse; indeterminate and disagree refuse as `ScmInitRefusedPathUnobserved`, carrying the host's cause. Executed, four cases: fresh directory writes 166 bytes; an existing repository refuses; a foreign file refuses and reads back byte-intact; a directory with mode 000 refuses naming `Permission denied (os error 13)` rather than widening to "nothing is there". Three hermetic claims enrolled beside them in test.claim.scm.scm_cli_witness -- no refusal renders as a success exit, the three refusals render differently, and the unobserved arm names what could not be observed. 2. The hand-authored Rust had no seed-growth receipt. `gunbc.cli_wire_host_admission` enumerates all 11 declarations at identity grain, states why the host process and the interpreter Value both lack a .dag denotation today, records that `exit_status_for_class` is a relocation rather than new behaviour, and names two capability-grain triggers. It does NOT admit the growth; the disposition is Terminal and fails closed without an operator ruling. Wired into `gunbc.seed_growth_admission`. 3. `gunbc.cli_dispatch_surface` still asserted there is NO route a user can invoke for the scm family, which this branch made false. The row now names the real route and says what changed, and its stage0 mirror is regenerated from the emitter (the candidate tree differed from the committed mirror by exactly this one string). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wy8wRfzTK2kvFzj9nAEbyT
seed_growth_admission conflicted on the justification roster: two lanes appended one row each — cli_wire_host_seed_growth_justification from this branch, required_lane_judgment_seed_growth_justification from main. The rows are independent, so both are kept, and each name was re-checked against its import edge rather than read off the diff: this carrier is exactly the shape merge_region_excludes_shared_tail describes, and its rows being one-liners is what makes the additive resolution safe here rather than something to assume. The generated projections are regenerated from the merged authorities via the sanctioned actuator, not resolved by picking a side. Verified locally on the merged tree with a claim_executor rebuilt from it: build lane regen first_generation_equal=true, generated-artifact 35/35 matched 0 drifted; witnesses lane 3 phases 0 failures, 4484 files parse-clean, namespace-wave-admission ADMITTED. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQ4iThiZ1B9LPB9ePr8qa9
# Conflicts: # src/v1/stage0/src/cli_run.rs
main carries an updated `proof` row in gunbc.rust_source_type_bindings whose committed projection was never regenerated, so origin/main is itself in a drifted state and every branch that merges it inherits a failing regen phase. Measured here rather than assumed: both the .dag authority and the .rs projection on this branch are byte-identical to origin/main, and regen still reports first_generation_equal=false for this one file. The fix is the projection, not the authority: the generated file now carries what the row says. The build lane is green at this head -- regen first_generation_equal=true, generated-artifact 35/35 matched, 0 drifted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQ4iThiZ1B9LPB9ePr8qa9
cli_wire_host_relocation_note was a `data ... : String` whose only purpose was commentary, with no consumer -- the neighbouring SeedGrowthJustification did not reference the symbol, it referenced the NAME inside its own prose. That is the state DESIGN §4c names: an ordinary String declaration carrying commentary is mechanically indistinguishable from program data, and `//` is the quarantine boundary that keeps the two apart. It is authored rationale about why the roster counts a relocation, so it becomes an annotation on the declaration it explains. The FACT it carried does not disappear with it: that exit_status_for_class is a relocation rather than new behaviour is now stated inside the justification's own `reason`, where a consumer reading the receipt sees it, rather than in a sibling row a consumer would have to know to look up. Found by review 58455 on gunbc#9864. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQ4iThiZ1B9LPB9ePr8qa9
Two of the four findings in review 5085479276 on gunbc#9864. THE NAME WAS USED TWICE AND GUARANTEED ONCE. initialize_repository asked the listing about `name` and separately joined directory + "/" + name into a path, with nothing making those the same subject. `subdir/repo.json` is asked about as a child of the listed directory while the join reaches past it, `../victim` leaves it entirely, `.`/`..`/`""` name no child at all, and -- the case that belongs to the filesystem module rather than to any caller -- a name carrying a newline can make the membership test answer yes for an entry that is not there, because newline is Filesystem.List's own delimiter. FilesystemEntryName is sole_constructor, so admission is unavoidable rather than advisory, and the refusal names which rule rejected the spelling. Its scope is stated honestly: it is adopted at this consumer, filesystem_entry_presence still takes a String, and widening that is a replacement migration over the whole population with its own next-rung trigger -- not smuggled into the change that introduces the carrier. THE DECISION WAS NOT WITNESSED, ONLY ITS RENDERING. Every enrolled init claim built a refusal outcome and checked how it printed, so nothing executed the step that CHOOSES an outcome from an observation; a mutation routing indeterminate to the write arm left the family green. scm_init_decision is that step with the two operations lifted out, and the four arms are now driven one fact apart through the REAL fold -- FilesystemEstablishedAbsence being sole_constructor means a witness cannot hand in a fabricated absence, which forces the evidence to cover filesystem_file_observation and the decision together. Executed: routing indeterminate to a present-refusal takes an_unobservable_path_does_not_authorize_a_write red; routing a real absence away from the write arm takes only_an_established_absence_may_create red. The third mutation -- routing DISAGREEMENT to the write arm -- is unwritable, because ScmInitMayCreate carries the sole_constructor absence and no arm can manufacture one. The rung is split on that boundary rather than averaged. The refusal vocabulary is one type (ScmInitRefusal) consumed by both the decision and the outcome, so a refusal has one spelling rather than two, and the outcome never carries an arm that is only ever intermediate. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQ4iThiZ1B9LPB9ePr8qa9
Third of the four findings in review 5085479276 on gunbc#9864. classify_cli_wire returned NotCliWire for two materially different situations: a value of some other type, and a value that NAMES CliWireResponse but does not inhabit it. cli_wire_outcome answers None for NotCliWire so the caller falls through to classify_exit, which meant a malformed wire response was reported as error: function `f` returned `...`, not `ProcessExit` -- true, useless, and about a type the value never claimed. It hides that the value claimed a type it does not inhabit, and it sends the caller to the wrong remedy: wrap this in ExitSuccess, when the actual defect is a missing `bytes`. MalformedCliWire is its own arm and REFUSES rather than falling through, exiting 2 like the other shape refusal. NotCliWire keeps its fall-through unchanged, which is what preserves every pre-existing ProcessExit entry point. THE TESTS THE REVIEW SAID DID NOT EXIST. All five original tests started from an already-classified CliWireClass, so classify_cli_wire had no coverage at all -- the boundary carrying the defect was the one nothing executed. Seven tests now build real interpreter values: a positive control that a well-formed printable still classifies as printable, a control that a plain ProcessExit and a bare string still fall through (the case that must NOT become malformed, or every existing entry point breaks), the four malformed shapes, and one that the refusal reaches the host with status 2 and no stdout. Executed: restoring the fall-through for MalformedCliWire takes a_malformed_wire_response_refuses_rather_than_falling_through red and leaves the other twelve green, so the arm is load-bearing for exactly the reported case. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQ4iThiZ1B9LPB9ePr8qa9
Adding cli_wire_classify_tests put thirteen hand-authored declarations into cli_run.rs that the receipt did not name, while it went on asserting "+11 FROM THE MERGE BASE, ENUMERATED ABOVE". A roster whose subject is hand-authored declarations, silently missing thirteen of them, is the defect it exists to prevent -- and it was introduced by the change that fixed a different one. Enumerated at 25 and recomputed: 449 insertions in cli_run.rs, 84 changed lines in main.rs, re-derived against origin/main rather than a stale local `main` ref. The five test HELPERS are counted, not just the seven test fns. The roster's subject is declarations, and a helper is one; waving them through as "just tests" is the same netting this receipt already refuses for the relocated exit_status_for_class. Two further .rs files differ and the row says WHY they are outside the subject rather than omitting them: gunbc_cli_dispatch_surface.rs and gunbc_rust_source_type_bindings.rs are generated projections regenerated from their .dag authority, so they are excluded by construction, not by exemption. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQ4iThiZ1B9LPB9ePr8qa9
Review 5085479276 on gunbc#9864, finding 1: initialize_repository established an absence from a directory listing and then called save_repository, which writes UNCONDITIONALLY. The decision was honest and the write was simply not conditioned on it, so an actor creating the file between the two made init truncate exactly the bytes it advertises that it refuses to touch. THIS IS NOT REPAIRABLE BY OBSERVING MORE CAREFULLY. Check-then-write is two acts with a gap, and re-observing only makes the gap smaller. The existence test and the creation have to BE one act, which is a property of open(2) with O_CREAT|O_EXCL and not something a fold over two modeled operations can express. Construction over validation at a boundary where validation structurally cannot win. So the substrate gains the operation rather than the caller gaining a check: extdeps.filesystem.filesystem_io WriteCreateNew, a FileWriteCreateNew verb in the emit stage, and one hand-authored realization. gunbc.scm.repository_save gains create_repository beside save_repository -- persisting a repository that exists and creating one that must not exist are different subjects, and the unconditional write stays correct for the first. NOT WriteOwnerOnly, WHICH ALREADY CALLS create_new. Its O_EXCL is incidental to setting a mode at creation -- meaningless on a path that already exists -- and is not a contract. Owner-only MODE and create-only EXISTENCE are independent facts; a caller taking create-only from it would silently also take 0600 and would break the day owner-only stopped needing O_EXCL. Reusing a realization detail in place of a modeled fact is the inversion §3 names, and the review rejected it explicitly before this landed. THE REFUSAL DOES NOT CLASSIFY ITSELF, and that is deliberate. It carries the host's error verbatim and does not report whether the cause was "already existed" or "permission denied": the transport's channels cannot separate them, and deciding it by matching the error TEXT would be a heuristic standing in for an observation. The caller learns what it needs in order to refuse and does not learn a classification nothing measured. That gap has its own next-rung trigger. Executed, and the mutation is the original defect rather than an invented one: replacing create_new with create+truncate -- what the code did before -- takes create_new_refuses_a_path_that_already_exists_and_leaves_its_bytes red while the positive control stays green. The load-bearing assertion is that the EXISTING BYTES SURVIVE, not that an error is returned: a write that truncated and then reported failure would satisfy a weaker test and still have destroyed the file. Seed growth is receipted in gunbc.filesystem_create_new_admission and rostered, with a trigger naming the capability (creation exclusivity declarable as a property of a write, with the realization deriving the flags) rather than an artifact. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQ4iThiZ1B9LPB9ePr8qa9
Two defects my own build lane caught rather than a reviewer. The WriteCreateNew annotation sat INSIDE the service body, which §4c refuses: only module-item grain is modeled, and an operation lives inside a service's declaration. It now sits above the service that contains it, and says why it is there rather than on the operation it describes -- so the next author does not repeat the move. The seed-growth row imported DeclarationRef and WholeDeclaration from gunbc.seed_growth, which does not export them; std.decl_ref does. Build lane green: regen first_generation_equal=true, generated-artifact 35/35, 0 drifted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQ4iThiZ1B9LPB9ePr8qa9
Three findings from the SCM reviewer, all of them leftovers from cutting init. DEAD ARTIFACTS THE CUT LEFT BEHIND. Removing scm_init_cli_response orphaned the whole save-rendering cluster -- repository_save_lines, scm_save_exit, scm_save_document, scm_save_cli_response -- which had no consumer outside render.dag once init was gone, plus the RepositorySave imports that fed them. The witness kept wire_refuses and wire_bytes under a header reading "SHARED BY THE INIT CLAIMS BELOW" with no init claims below. Measured before deleting: the two helpers appeared exactly twice in the file, which was their own definitions. They return in #10026 with scm_init_lines, their actual consumer. THE RECEIPT UNDERCOUNTED FOR THE SECOND TIME, and the repair is the derivation rather than the number. It said +11 while a test module added thirteen unlisted items; corrected to +25, it then missed a_printable_response_whose_exit_is_not_a_process_exit_is_malformed_and_prints_nothing -- the test that closed review 58518, added after the row was written. Both times the receipt was edited as a step separate from the code it admits, so anything added afterwards fell silently outside its subject. The row now states how to re-derive the population instead of asserting a total: per test module, the module itself plus every fn carrying #[test] plus every fn that does not. 5 + (1 + 0 + 5) + (1 + 5 + 9) = 26. Child items also carry their NESTED module path now (v1_compiler.cli_run.cli_wire_classify_tests), because that is where the declaration lives and a roster whose identities do not resolve cannot be checked against the code. The outcome module's five were flattened onto the parent when first written and are corrected here rather than left as a second convention. THE PR NARRATIVE claimed init landed -- title, execution receipt, and a paragraph describing the unsafe behaviour as a known limitation. Rewritten as the slice this actually is: CliWireResponse bound once in the gunbc run outcome seam, making log and status observable, with init's removal stated as a deferral and its defect described rather than softened. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQ4iThiZ1B9LPB9ePr8qa9
…ions the driver refused The generated-artifact merge driver fired on v1_compiler_emit.rs and v1_compiler_emit_rust.rs: both sides changed them since the merge base, so neither side's bytes are the projection of the MERGED authorities. It refused, left them unmerged with no conflict markers, and printed the repair. Resolved by regeneration, not by picking a side. main's copies were installed only as a BOOTSTRAP -- main added unmodeled_shell_transport_diagnostics plus a consumer in the hand-written v1_compiler_compile.rs, so the ours side no longer compiled and no seed could run the regenerator at all. The regenerated bytes carry BOTH main's unmodeled_shell_transport_diagnostics AND this branch's FileWriteCreateNew. Neither side alone was correct, which is exactly the drop the driver exists to prevent. Evidence, per the driver's own recipe: pass 1 (bootstrap seed): FAIL naming exactly those two files; candidate installed pass 2 (seed rebuilt from the installed candidate): first_generation_equal=true fixed point: fixed_point_equal=true referenced_first_generation_equal=true The recipe warns that pass 1 runs a binary predating the change it emits, so one pass can self-verify at divergence 0 for the wrong reason. Hence passes 2 and 3. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQ4iThiZ1B9LPB9ePr8qa9
…o write renderer Reviewer's native 5089156132 items 1 and 2 on #9864. 1. The PR body's "Named, not hidden" section still named a defect in the write renderer's handling of RepositoryEncodeRefusal arms. There is no write renderer in this PR -- it left with init for #10026. Bullet removed. 2. Three incompatible time claims in docs/plans/scm-demo-cli-rebuild.md: - It opened "It is a plan, not a receipt: nothing here claims to be built" and then carried a LANDED section full of execution receipts. Now states that it is a plan AND a historical ledger, separated by section, and that a present-tense statement in the plan half describes the BASELINE rather than the tree today. - It said in the present tense that scm_log_cli_response and scm_status_cli_response have NO consumer. This branch adds that consumer. Marked as the baseline gap this change closes, pointing at the receipts. - It presented `Commands::Scm` + `scm_verb` as the required host binding. That is NOT what landed, deliberately: binding CliWireResponse once in the generic run_verb outcome seam makes EVERY wire-returning entry reachable instead of only the scm family -- one binding rather than one per verb. Now marked as a future ergonomic surface rather than unfinished work required by the binding that shipped. The deferred-init subsection the reviewer accepted is untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQ4iThiZ1B9LPB9ePr8qa9
briansrls
left a comment
There was a problem hiding this comment.
Exact-head native review — 3a1d1729c5251865002cf9cb7d360879690e1ea3
REQUEST_CHANGES
The central correction is real and accepted as far as it goes: WriteCreateNew is a distinct modeled fact from WriteOwnerOnly; the interpreted host path uses OpenOptions::create_new(true); and the existing-path test asserts the load-bearing consequence that pre-existing bytes survive. That closes the original check-then-unconditional-write truncation route.
It does not yet make the complete init model safe or truthful. Six bounded findings remain.
1. ScmInitialized can contain a failed creation
initialize_repository_at currently does this:
ScmInitMayCreate(_) =>
ScmInitialized { save: create_repository(...) }
But create_repository returns RepositorySave, whose arms include codec refusal, file-unwritable, and unrepresentable byte count as well as RepositorySaved. Therefore the model can produce values such as:
ScmInitialized { save: RepositoryFileUnwritable { ... } }
The renderer notices the nested failure and exits nonzero, but that does not repair the carrier. Any other consumer matching the outer arm is told initialization happened when it did not. A false success state is representable and is produced on the ordinary write-refusal path.
Match the save result before constructing the init outcome. Only RepositorySaved may construct ScmInitialized; every other save arm needs a refusal/indeterminate init arm, or the outer arm must be renamed so it does not assert success. Execute the total fold over all four RepositorySave arms; a mutation that wraps a failed save as initialized must go red.
2. The established-absence authority is discarded before actuation
The comments claim that ScmInitMayCreate carries the same unforgeable FilesystemEstablishedAbsence that authorizes the write. The implementation instead matches it as _ and actuates with an independently computed raw path:
ScmInitMayCreate(_) => create_repository(path: path, ...)
scm_init_decision also receives path separately from the observation, and create_repository is publicly callable with any String. Consequently the sole-constructor token neither identifies nor is consumed by the write. An absence established for subject A can coexist with an actuation against subject B; changing only the write path remains writable.
Choose one honest model:
- make the absence a real subject-bearing capability consumed by the create actuation, with the path derived inside that authority and no independently supplied target; or
- state that the preflight is diagnostic only and that
O_EXCLis the actual safety wall, renaming/removing the unforgeable-authorization claim.
In either case, add a discriminating control showing that evidence about one directory entry cannot authorize creation of another.
3. WriteCreateNew erases the post-create write-failure state
Both realizations perform two host steps after the modeled operation is admitted:
open(... create_new(true))
write_all(content)
If the open succeeds and write_all then fails, the target file has already been created and may contain zero or partial bytes. The helper returns an ordinary error; the transport reports success=false, bytes_written=0; and create_repository classifies it only as RepositoryFileUnwritable. The admission prose says the operation reports that the create did not happen, which is false in this branch.
The two current tests cover only (a) refusal before creation because the target already exists and (b) complete success. Neither observes a failure after exclusive creation.
Do not collapse “nothing was created” with “a new incomplete artifact now exists.” Model the two dispositions, or provide cleanup whose own failure is represented; alternatively use a construction that publishes the target only after complete content is ready. Add an injected or otherwise deterministic post-open write-failure control. This is separate from the original overwrite race: the existing bytes are safe, but the repository path can be poisoned by a failed initialization.
4. FilesystemEntryName does not yet guarantee one host entry on every target this operation supports
The original filename bar required no path separator and no NUL. The admission rejects /, but not \\, and it does not reject NUL.
WriteCreateNew is implemented on non-Unix hosts rather than refusing there. On Windows, a spelling such as ..\\victim is a parent traversal even though it passes this carrier, so the listing is asked about one child while the joined actuation path names another subject—the exact defect the carrier claims to make impossible.
Either reject every path separator for every supported target (including \\) and NUL, with controls, or explicitly scope/refuse the operation on targets whose path grammar the carrier does not model. The current Unix-only examples are not sufficient for a portable realization.
5. The evidence leaves three load-bearing surfaces mutable
- The mutation receipt reaches
v1_interpreter::write_file_create_new, butsrc/v1/05_emit_rust.dagindependently spells another realization infile_write_create_new_expr. Replacing that emitted expression'screate_new(true)with an ordinary create/truncate leaves the two Rust unit tests green. The new emitter verb needs an executing emitted-program probe, or a construction that prevents the two realizations from drifting. present_bytes_do_not_authorize_a_write_and_are_classifiedproves invalid bytes becomeforeign_present, but for a valid repository it asserts only!= may_create. Mapping every present file—including a valid repository—toScmInitForeignFilePresentwould leave it green. PinRepositoryDecoded -> ScmInitRepositoryPresentone fact apart from the foreign-file case.ScmInitNameNotAnEntryis now a fourth refusal arm, butan_init_refusal_never_exits_successandthe_three_init_refusals_render_differentlystill enumerate only the prior three. The direct admission test proves early refusal, not that this new arm renders distinctly and exits failure. Execute it throughscm_init_responseand update the claimed population.
6. Compose the final #9864 authority and current main before the next candidate
This branch still carries an older copy of the #9864 CLI slice: gunbc.scm.cli again says “every gunbc scm answer,” retains an unused empty_repository import, and the plan again claims it is only a plan while containing landed receipts, presents the direct subcommand as the required host binding, names save_repository as init's actuation, and carries the obsolete pre-#9891 add/commit recipe.
Do not repair those independently into a second version. #9864 is the predecessor and must land first; then compose its final authority, preserve only #10026's init/create-only delta, regenerate every overlapping projection from the composed authority, and establish the fixed point.
This exact head contains main@a0f03e41c992c9c1f4d404bda099804494b2629b; current main is 533264517ca822002519b96cc670c7832dcbc87f, and workflow 33631978217 is still nonterminal. Fresh terminal exact-head CI is required after the model and composition repairs.
Re-review bar
- Make success/refusal truthful at the
ScmInitOutcomeouter arm. - Either consume the established-absence subject in actuation or narrow the authority claim honestly.
- Represent or eliminate the created-but-incomplete failure state.
- Close the cross-target filename grammar, including backslash and NUL, or scope the target.
- Add discriminating evidence for the emitted realization, valid-repository classification, and the fourth renderer refusal.
- Compose final #9864 plus current main, regenerate to fixed point, and obtain terminal exact-head green CI.
No objection is raised to keeping ordinary save_repository for replacement writes, or to modeling create-only separately from owner-only. No approval carries on 3a1d172.
…s PR's fixes #10026 merged before #9864 while carrying an older copy of THIS PR's CLI slice, so main briefly regressed four reviewer-mandated fixes. That is the predecessor- ordering hazard review 5089156132 named in its own finding 6, realized in the opposite direction from the one it anticipated: it warned #10026 must not repair the slice independently, and instead #10026's stale copy landed first. Resolved per file on merits, not by picking a side: scm/cli.dag main's init wiring (init is REAL now) + my two fixes re-applied: "every SCM read answer" (there is no `gunbc scm` subcommand) and dropping the unused empty_repository import, which is still unused because init calls empty_repository() inside init.dag. scm_cli_witness_test.dag main. The init claims were removed from this PR correctly when init was not in it; init has landed, so they belong again. cli_dispatch_surface.dag main. Its successor row says init IS reachable, which is now true. The row must describe the tree, not the PR. seed_growth_admission main. Adds the filesystem_create_new roster entry. cli_wire_host_admission MINE. The 26-declaration derivation with nested module identities is what review 5089156132 required; main's copy reverted it to a flat path and an undercount. scm-demo-cli-rebuild.md NEITHER side was right. Kept my three scoping fixes (plan-vs-ledger, the baseline-gap framing, Commands::Scm as future ergonomics) and reframed the init subsection from DEFERRED to LANDED-with-open-findings, naming the two model defects review found in #10026. cli_run.rs Four conflicted hunks resolved to mine (the review-58518 malformed-wire refusal and the review-58567 doc repair). Everything outside those hunks is main's, which MATTERS: main added a cwd-mutation reachability module that a wholesale ours-resolution would have deleted. Verified present after resolution. gunbc_cli_dispatch_surface.rs Generated projection, not hand-merged. Took main's bytes and re-derived: required-regen reports first_generation_equal=true, so those bytes ARE the projection of the composed authority. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQ4iThiZ1B9LPB9ePr8qa9
#10026 landed with these open. Each was verified against the code before being accepted, and each carries a control that goes red on the defect. 1. ScmInitialized could contain a FAILED creation. It carried the whole RepositorySave, so `ScmInitialized { save: RepositoryFileUnwritable { .. } }` was spellable and told every consumer matching the outer arm that initialization happened. The renderer noticed and exited nonzero, which repaired the message and not the carrier. ScmInitialized now carries only { path, written } from a successful create; init_outcome_of_save is a total fold over all four save arms and every other arm reaches ScmInitWriteRefused. 2. The established-absence authority was discarded before actuation. The code matched ScmInitMayCreate(_) and wrote to an independently supplied path, so an absence established for one subject could sit beside a create against another and no mutation to the write target went red. The token carries { directory, name }, so create_repository_at_absence now DERIVES the target from it -- there is no second target to disagree with. The rung prose is corrected rather than softened. Four external reviews read the sole_constructor and endorsed a rung-4 authorization claim; the designated reviewer read the MATCH and found the token was never consumed. A capability carried past its actuation authorizes nothing. The annotation now splits the rung honestly and says plainly that no preflight closes a TOCTOU -- O_EXCL does -- so the observation decides what the operator is told and which subject may be written, not whether the race exists. 3. WriteCreateNew erased the post-create write-failure state. open-then-write leaves a created, zero-or-partial file if write_all fails, while the model reports the create did not happen. That is worse than the overwrite race this operation closed: the race could destroy someone else's bytes, this FABRICATES a repository nobody wrote. Fixed by construction rather than by modeling two dispositions -- content is staged to an O_EXCL sibling and the target name is claimed with hard_link, which fails if the target exists, so exclusivity moves to the publish step and every earlier failure leaves the target absent. THE FIRST CONTROL I WROTE FOR THIS WAS A DECORATION, and the dead attempt is documented in the test module so it is not rebuilt. It put a DIRECTORY at the target; measured against the old construction it PASSED, because create_new fails at the OPEN when the name exists so nothing was ever created. The replacement injects RLIMIT_FSIZE in a forked child so the create succeeds and the write fails -- the one ordering that separates the constructions. Proven both ways: green on the fix, RED on the pre-fix construction with "a write that failed after creation must leave NO target behind". 4. FilesystemEntryName rejected `/` but admitted backslash and NUL while WriteCreateNew is implemented on non-Unix. `..\victim` is a parent traversal there, and a NUL makes the syscall see a PREFIX of the name the listing was asked about. The grammar is the UNION over supported targets, not the one the author happens to run on. Still open from that review: finding 5 (the emitted realization in 05_emit_rust.dag is independently mutable, plus two weak assertions) and finding 6 (compose #9864's final authority, which must land first). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQ4iThiZ1B9LPB9ePr8qa9
…or (#9864) * Record the SCM demo CLI rebuild plan and its API inventory A working 'gunbc scm' CLI existed and was lost with an uncommitted /tmp worktree, along with the plan doc describing it. This commits the survey so the rebuild does not start by re-reading every scm module, and so the next context loss costs nothing. The useful finding from re-surveying: the gap is smaller and more specific than 'build a CLI'. gunbc.scm.render ALREADY reaches CliWireResponse via scm_log_cli_response and scm_status_cli_response -- they simply have no consumer outside dag/test/claim/scm/scm_render_witness_test.dag, which is the unwired-renderer state gunbc.cli_dispatch_surface already records. It also states why the corpus's idiomatic instrument shape cannot substitute for the host binding: 'fn check(...) -> ProcessExit' can only emit text on FAILURE (exit_failure's reason), and log/status must print on success. That is the argument for the main.rs work rather than a workaround, and it is the thing I would otherwise have had to re-derive. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wy8wRfzTK2kvFzj9nAEbyT * Host reader and pure outcome map for CliWireResponse gunbc.scm.render already produces CliWireResponse (scm_log_cli_response, scm_status_cli_response) and nothing outside its witness test consumes it -- the answer is computed and discarded, which is the unwired-renderer state gunbc.cli_dispatch_surface records. This adds the two host-side pieces that binding needs. classify_cli_wire sits beside classify_exit as the single authority for reading the variant shape, so the driver seam cannot fork it. A missing or wrongly-shaped field is NotCliWire rather than a default: defaulting bytes to "" would print nothing and exit 0, which is a fabricated plausible output. cli_wire_outcome is the total, pure map from that class to what the host does -- bytes, status, message. Pure for the reason exit_status_for records about itself: the inlined version of that decision dropped a case and reported success for every failure. It lives in the LIB, not beside the driver, because main.rs is a BIN target and the rust-unit-tests job runs 'cargo test -p v1-compiler --lib' -- a test written next to the driver is compiled by clippy and executed by nobody. exit_status_for_class is shared by both paths so the wire path and the plain ProcessExit path cannot disagree about what a verdict means. NotCliWire yields None rather than a failure, so the driver falls through to classify_exit and every existing ProcessExit entry behaves exactly as before; a test pins that. 5 executing witnesses: bytes written with the response's own exit, a rendered answer that still fails, the renderer's refusal not reading as an empty success, the fall-through, and the inherited ExitFailure{code:0} refusal. NOT YET WIRED into run_verb -- that is the next commit, and until it lands this reader has no production consumer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wy8wRfzTK2kvFzj9nAEbyT * Wire CliWireResponse into run_verb: the SCM answer reaches an operator gunbc.scm.render has produced CliWireResponse since it was written, and outside its witness test nothing consumed it. A function returning one hit classify_exit's NotProcessExit arm and the run refused with 'wrap the result in ExitSuccess / ExitFailure', so every gunbc scm answer was computed and discarded -- the unwired-renderer state gunbc.cli_dispatch_surface records. run_verb now tries cli_wire_outcome first and falls through to classify_exit unchanged for every other value. Binding it in the OUTCOME SEAM rather than under a new 'scm' subcommand keeps dispatch peripheral (§3): one binding serves every wire-returning entry instead of the host growing an arm per verb. dag/gunbc/scm/cli.dag is the entry the host invokes -- scm_log and scm_status, composing the read side onto a declared plain-terminal capability. The capability is declared, not detected: a host that learns to report a real one passes it in. PROVEN BY EXECUTION, not by typecheck: gunbc run --entry dag/gunbc/scm/cli.dag --function scm_log --arg path=/tmp/no-such-repo cannot read repository at /tmp/no-such-repo No such file or directory (os error 2) repository unavailable EXIT=1 That is the renderer's own document on stdout AND a nonzero exit -- the 'printable response carrying a failing exit' case, which is the one an absorbing implementation would have turned into either silence or a spurious 0. Also dissolves a fork I had just created: main.rs's exit_status_for carried its own copy of the verdict map, and once the wire path needed the same decision that copy was two places free to drift. The body now lives in cli_run::exit_status_for_class, where an executing test can reach it -- rust-unit-tests runs 'cargo test --lib', and main.rs is a bin whose tests are compiled by clippy and run by nobody. clippy --all-targets -D warnings: clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wy8wRfzTK2kvFzj9nAEbyT * Record the working read surface and state its evidence boundary Receipts for the three executed cases, including the load-bearing one (bytes printed AND a nonzero exit), so the next session does not re-derive them. States the rung honestly rather than implying the e2e is covered: the five cli_wire_outcome tests execute in CI, and the end-to-end runs are a MANUAL receipt. An integration test would live in src/v1/tests/, which clippy compiles and no CI step runs; a .dag witness cannot substitute because scm_log reads a file and the SCM witnesses are SubstrateInputsOnly. So the e2e path is mitigatable and its next-rung trigger is a CI step that runs an integration target -- not another test file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wy8wRfzTK2kvFzj9nAEbyT * Make gunbc.scm.cli's own decisions witnessable, and enroll them gunbc.scm.render is already witnessed against PlainTerminal and an ASCII no-colour capability. Nothing held gunbc.scm.cli to passing THOSE: setting color: true on plain_terminal_capability would have turned no claim red, which is the inert-check shape DESIGN calls worse than absent. The composition is split from the read -- scm_log_response / scm_status_response take the read's RESULT and are pure, while scm_log / scm_status supply it from a path. That split is what makes the claims authorable at all: the verbs read a file and this witness family is SubstrateInputsOnly, so a claim can never call them. Five enrolled claims: the unavailable-repository bytes (pinned to the same expected string the render-level witness uses, so an equal answer is evidence the PLAIN target and capability were passed, not merely that something was), its failing exit, the empty-log answer with a succeeding exit, the same for status, and the capability row itself so a reader editing it learns it is a contract. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wy8wRfzTK2kvFzj9nAEbyT * scm init: the first write verb, and a real round trip gunbc run --entry dag/gunbc/scm/cli.dag --function scm_init --arg path=/tmp/demo-repo.json initialized repository at /tmp/demo-repo.json rc=0, file created ... --function scm_log --arg path=/tmp/demo-repo.json no commits yet init writes the document, log reads it back. That settles the open question the plan doc recorded: a host WRITE is permitted from gunbc run, so the remaining write verbs are a modeling question rather than a permissions one. render.dag gains the write-answer renderer. A write verb answers with what the write DID, and its four outcomes are not one line with a flag -- each names a different failure and a different next action. RepositoryWriteByteCountUnrepresentable EXITS FAILURE even though bytes reached the disk: a write whose reported size is not a magnitude has not been confirmed, and reporting success for it is the fabricated plausible output §5 forbids. TWO LOSSES NAMED IN THE SOURCE RATHER THAN HIDDEN: - The codec refusal carries a typed RepositoryEncodeRefusal with six arms (uncontained target, root not in store, checkout not in commits, duplicated reference, reference outside allocator, parent not in commits) and this renderer does not decompose it, so an operator learns THAT encoding refused and not WHICH invariant failed. Rendering it needs a per-arm function over ObjectId and RepositoryCommitRef. - init does NOT refuse an existing repository. save_repository writes unconditionally, so init over a populated path overwrites it. Refusing needs a read-before-write that is not expressible as one outcome here. That is why this verb is not offered as a safe default. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wy8wRfzTK2kvFzj9nAEbyT * Record what add and commit need, including the one design step Settles by execution what was open: a host write IS permitted from gunbc run, content enters via store_node, a Node is built with node_synthetic (the test modules' 'atom' is a LOCAL helper, not an authority), and mint_repository_commit requires store_contains(root), so add must precede commit -- and because identity is content-derived, commit can re-derive an added object's ObjectId by rebuilding the same node. Names the design step rather than sketching it: add composes store_node's 2 arms with save_repository's 4, and commit composes mint's 4 with the same save. A renderer taking two outcome values would encode 'which one failed' positionally and the arms multiply. One modeled ScmWriteOutcome is the increment's real content. I deliberately did not improvise it into cli.dag at the end of a long session, because a write-verb outcome invented at a call site is the anemic modeling this repository keeps paying for. Also records that 'add' has no staging authority to persist to -- repository_status takes pending as a PARAMETER because what is staged is not a fact the document carries -- so a literal stage-now-commit-later add needs a staging authority to exist first. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wy8wRfzTK2kvFzj9nAEbyT * init refuses an occupied path: naming a destructive default did not make it fail-closed Review 58044 was right and this was the worst thing in the PR. save_repository writes unconditionally -- correctly, it is persistence and not policy -- so a verb offering initialization has to decide for itself whether there is anything at the path it would destroy. The previous revision skipped that decision, called save directly, and NAMED the overwrite in a comment. DESIGN §5's review bar is that a diff landing a non-fail-closed failure arm is a hard reject regardless of what else it delivers; an annotation is not a refusal. gunbc.scm.init owns the decision, because whether there is anything to destroy is a fact about the repository and deciding it in the CLI module would put policy in the realization layer. ScmInitOutcome has three arms and no 'initialized: Bool' beside a save outcome -- a value whose flag disagreed with its shape would have no spelling -- and the two refusals are distinct because the operator's next action differs: a path that already IS a repository is not the same as a path holding someone else's bytes. MEASURED, including the case that proves the destruction is actually closed: fresh path -> initialized repository at /tmp/d2.json same path again -> refusing to initialize /tmp/d2.json a repository is already there, and init would replace its whole history foreign file -> refusing to initialize /tmp/foreign.json a file is already there that is not a repository, and init would destroy it foreign file after-> 'not a repo' (intact -- read back, not assumed) THE RESIDUAL IS STATED IN THE MODULE RATHER THAN IMPLIED. RepositoryFileUnreadable fuses 'absent' with 'present but unreadable' and carries the host's error STRING. This module refuses to branch on that text, because deciding a destructive question by matching an errno spelling is the stringly reasoning the substrate exists to remove. So the proceed arm is 'unreadable', not 'absent'. Closing it needs a modeled path-existence observation distinct from a read failure, which does not exist yet and is the module's next-rung trigger. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wy8wRfzTK2kvFzj9nAEbyT * Make scm init proceed only from an established absence, and receipt the wire host Rust Three blocking findings from review 58060 on #9864. 1. `init` was fail-open on `RepositoryFileUnreadable`. Proceeding there is a guess about the host's permission model, not an observation -- a file can be unreadable and perfectly truncatable, so the proceed arm could destroy the bytes it existed to protect. The residual was NAMED in the module header, and a named residual does not refuse. `initialize_repository` now takes a DIRECTORY and a NAME and routes through `extdeps.filesystem.filesystem_io` `filesystem_file_observation`: absence is established from a listing that succeeded and did not name the entry, and only that arm reaches `save_repository`. Present bytes are classified (repository vs foreign) for the operator's benefit but both refuse; indeterminate and disagree refuse as `ScmInitRefusedPathUnobserved`, carrying the host's cause. Executed, four cases: fresh directory writes 166 bytes; an existing repository refuses; a foreign file refuses and reads back byte-intact; a directory with mode 000 refuses naming `Permission denied (os error 13)` rather than widening to "nothing is there". Three hermetic claims enrolled beside them in test.claim.scm.scm_cli_witness -- no refusal renders as a success exit, the three refusals render differently, and the unobserved arm names what could not be observed. 2. The hand-authored Rust had no seed-growth receipt. `gunbc.cli_wire_host_admission` enumerates all 11 declarations at identity grain, states why the host process and the interpreter Value both lack a .dag denotation today, records that `exit_status_for_class` is a relocation rather than new behaviour, and names two capability-grain triggers. It does NOT admit the growth; the disposition is Terminal and fails closed without an operator ruling. Wired into `gunbc.seed_growth_admission`. 3. `gunbc.cli_dispatch_surface` still asserted there is NO route a user can invoke for the scm family, which this branch made false. The row now names the real route and says what changed, and its stage0 mirror is regenerated from the emitter (the candidate tree differed from the committed mirror by exactly this one string). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wy8wRfzTK2kvFzj9nAEbyT * Regenerate the Rust source-type bindings from their authority main carries an updated `proof` row in gunbc.rust_source_type_bindings whose committed projection was never regenerated, so origin/main is itself in a drifted state and every branch that merges it inherits a failing regen phase. Measured here rather than assumed: both the .dag authority and the .rs projection on this branch are byte-identical to origin/main, and regen still reports first_generation_equal=false for this one file. The fix is the projection, not the authority: the generated file now carries what the row says. The build lane is green at this head -- regen first_generation_equal=true, generated-artifact 35/35 matched, 0 drifted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQ4iThiZ1B9LPB9ePr8qa9 * The relocation note was prose in a String row, which §4c quarantines cli_wire_host_relocation_note was a `data ... : String` whose only purpose was commentary, with no consumer -- the neighbouring SeedGrowthJustification did not reference the symbol, it referenced the NAME inside its own prose. That is the state DESIGN §4c names: an ordinary String declaration carrying commentary is mechanically indistinguishable from program data, and `//` is the quarantine boundary that keeps the two apart. It is authored rationale about why the roster counts a relocation, so it becomes an annotation on the declaration it explains. The FACT it carried does not disappear with it: that exit_status_for_class is a relocation rather than new behaviour is now stated inside the justification's own `reason`, where a consumer reading the receipt sees it, rather than in a sibling row a consumer would have to know to look up. Found by review 58455 on gunbc#9864. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQ4iThiZ1B9LPB9ePr8qa9 * Admit the entry name, and make the init decision witnessable Two of the four findings in review 5085479276 on gunbc#9864. THE NAME WAS USED TWICE AND GUARANTEED ONCE. initialize_repository asked the listing about `name` and separately joined directory + "/" + name into a path, with nothing making those the same subject. `subdir/repo.json` is asked about as a child of the listed directory while the join reaches past it, `../victim` leaves it entirely, `.`/`..`/`""` name no child at all, and -- the case that belongs to the filesystem module rather than to any caller -- a name carrying a newline can make the membership test answer yes for an entry that is not there, because newline is Filesystem.List's own delimiter. FilesystemEntryName is sole_constructor, so admission is unavoidable rather than advisory, and the refusal names which rule rejected the spelling. Its scope is stated honestly: it is adopted at this consumer, filesystem_entry_presence still takes a String, and widening that is a replacement migration over the whole population with its own next-rung trigger -- not smuggled into the change that introduces the carrier. THE DECISION WAS NOT WITNESSED, ONLY ITS RENDERING. Every enrolled init claim built a refusal outcome and checked how it printed, so nothing executed the step that CHOOSES an outcome from an observation; a mutation routing indeterminate to the write arm left the family green. scm_init_decision is that step with the two operations lifted out, and the four arms are now driven one fact apart through the REAL fold -- FilesystemEstablishedAbsence being sole_constructor means a witness cannot hand in a fabricated absence, which forces the evidence to cover filesystem_file_observation and the decision together. Executed: routing indeterminate to a present-refusal takes an_unobservable_path_does_not_authorize_a_write red; routing a real absence away from the write arm takes only_an_established_absence_may_create red. The third mutation -- routing DISAGREEMENT to the write arm -- is unwritable, because ScmInitMayCreate carries the sole_constructor absence and no arm can manufacture one. The rung is split on that boundary rather than averaged. The refusal vocabulary is one type (ScmInitRefusal) consumed by both the decision and the outcome, so a refusal has one spelling rather than two, and the outcome never carries an arm that is only ever intermediate. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQ4iThiZ1B9LPB9ePr8qa9 * A malformed wire response is not "some other type" Third of the four findings in review 5085479276 on gunbc#9864. classify_cli_wire returned NotCliWire for two materially different situations: a value of some other type, and a value that NAMES CliWireResponse but does not inhabit it. cli_wire_outcome answers None for NotCliWire so the caller falls through to classify_exit, which meant a malformed wire response was reported as error: function `f` returned `...`, not `ProcessExit` -- true, useless, and about a type the value never claimed. It hides that the value claimed a type it does not inhabit, and it sends the caller to the wrong remedy: wrap this in ExitSuccess, when the actual defect is a missing `bytes`. MalformedCliWire is its own arm and REFUSES rather than falling through, exiting 2 like the other shape refusal. NotCliWire keeps its fall-through unchanged, which is what preserves every pre-existing ProcessExit entry point. THE TESTS THE REVIEW SAID DID NOT EXIST. All five original tests started from an already-classified CliWireClass, so classify_cli_wire had no coverage at all -- the boundary carrying the defect was the one nothing executed. Seven tests now build real interpreter values: a positive control that a well-formed printable still classifies as printable, a control that a plain ProcessExit and a bare string still fall through (the case that must NOT become malformed, or every existing entry point breaks), the four malformed shapes, and one that the refusal reaches the host with status 2 and no stdout. Executed: restoring the fall-through for MalformedCliWire takes a_malformed_wire_response_refuses_rather_than_falling_through red and leaves the other twelve green, so the arm is load-bearing for exactly the reported case. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQ4iThiZ1B9LPB9ePr8qa9 * The seed-growth receipt undercounted the module I had just added Adding cli_wire_classify_tests put thirteen hand-authored declarations into cli_run.rs that the receipt did not name, while it went on asserting "+11 FROM THE MERGE BASE, ENUMERATED ABOVE". A roster whose subject is hand-authored declarations, silently missing thirteen of them, is the defect it exists to prevent -- and it was introduced by the change that fixed a different one. Enumerated at 25 and recomputed: 449 insertions in cli_run.rs, 84 changed lines in main.rs, re-derived against origin/main rather than a stale local `main` ref. The five test HELPERS are counted, not just the seven test fns. The roster's subject is declarations, and a helper is one; waving them through as "just tests" is the same netting this receipt already refuses for the relocated exit_status_for_class. Two further .rs files differ and the row says WHY they are outside the subject rather than omitting them: gunbc_cli_dispatch_surface.rs and gunbc_rust_source_type_bindings.rs are generated projections regenerated from their .dag authority, so they are excluded by construction, not by exemption. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQ4iThiZ1B9LPB9ePr8qa9 * Cut scm.init from this PR, and refuse a printable whose exit is not one TWO CHANGES, BOTH SUBTRACTIONS OF A CLAIM THIS BRANCH COULD NOT HONESTLY MAKE. 1. scm.init is removed, on the operator's ruling. Its remaining defect is a destructive TOCTOU: initialize_repository establishes an absence and then calls save_repository, which writes UNCONDITIONALLY, so an actor creating the file in between makes init truncate the bytes it advertises that it refuses to touch. The honest fix is an atomic create-only write, which needs a new file-transport verb in src/v1/05_emit.dag and 05_emit_rust.dag -- a load-bearing pipeline stage that a review finding about a CLI verb is not authority to extend. So init and the primitive land together in their own PR, where the emit-stage change can be judged on its own merits, rather than riding in on a CLI-wiring branch. What goes with it, because it would otherwise be an artifact with no consumer: the FilesystemEntryName admission carrier and the init decision witnesses. They are preserved on scm-init-create-only, not discarded. Two rows asserted the thing that is no longer true and are corrected rather than left to rot: cli_dispatch_surface said scm_init was reachable, and the seed growth receipt said "log, status and init reach an operator". Both now say init is deliberately absent and why. A surface that keeps asserting a reachability the host does not provide is the §3 fork the first of those rows exists to prevent -- it was wrong in the other direction two changes ago. 2. classify_cli_wire accepted any PRESENT `exit` field, including one that classify_exit answers NotProcessExit for. That value reached Printable, so cli_wire_outcome set stdout: Some(bytes) and then exited 2 -- the host PRINTED bytes carried by a value that does not inhabit the declared wire shape, and a consumer reading stdout got partial output from a response that was refused. Present is not the same as valid. Only a real exit keeps a response printable. Executed: restoring the accept-any-present-exit form takes a_printable_response_whose_exit_is_not_a_process_exit_is_malformed_and_prints_nothing red and leaves the other thirteen green. The assertion that carries the finding is `stdout.is_none()` -- a classification left as Printable would still refuse, but only after emitting the untrusted bytes. Found by review 58518 on gunbc#9864. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQ4iThiZ1B9LPB9ePr8qa9 * Regenerate the dispatch-surface projection from its corrected authority The row that stopped claiming scm_init is reachable is an authority with a committed projection, so the projection moves with it. Build lane green at this head: regen first_generation_equal=true, generated-artifact 35/35, 0 drifted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQ4iThiZ1B9LPB9ePr8qa9 * Take init's private artifacts out with it, and derive the receipt count Three findings from the SCM reviewer, all of them leftovers from cutting init. DEAD ARTIFACTS THE CUT LEFT BEHIND. Removing scm_init_cli_response orphaned the whole save-rendering cluster -- repository_save_lines, scm_save_exit, scm_save_document, scm_save_cli_response -- which had no consumer outside render.dag once init was gone, plus the RepositorySave imports that fed them. The witness kept wire_refuses and wire_bytes under a header reading "SHARED BY THE INIT CLAIMS BELOW" with no init claims below. Measured before deleting: the two helpers appeared exactly twice in the file, which was their own definitions. They return in #10026 with scm_init_lines, their actual consumer. THE RECEIPT UNDERCOUNTED FOR THE SECOND TIME, and the repair is the derivation rather than the number. It said +11 while a test module added thirteen unlisted items; corrected to +25, it then missed a_printable_response_whose_exit_is_not_a_process_exit_is_malformed_and_prints_nothing -- the test that closed review 58518, added after the row was written. Both times the receipt was edited as a step separate from the code it admits, so anything added afterwards fell silently outside its subject. The row now states how to re-derive the population instead of asserting a total: per test module, the module itself plus every fn carrying #[test] plus every fn that does not. 5 + (1 + 0 + 5) + (1 + 5 + 9) = 26. Child items also carry their NESTED module path now (v1_compiler.cli_run.cli_wire_classify_tests), because that is where the declaration lives and a roster whose identities do not resolve cannot be checked against the code. The outcome module's five were flattened onto the parent when first written and are corrected here rather than left as a second convention. THE PR NARRATIVE claimed init landed -- title, execution receipt, and a paragraph describing the unsafe behaviour as a known limitation. Rewritten as the slice this actually is: CliWireResponse bound once in the gunbc run outcome seam, making log and status observable, with init's removal stated as a deferral and its defect described rather than softened. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQ4iThiZ1B9LPB9ePr8qa9 * Restore the sentence the relocation dropped, and stop the doc claiming care exit_status_for_class's doc said "kept byte-for-byte equivalent to the driver's own exit_status_for" while the relocation had silently dropped the trailing `status: refused -- printing the value and exiting 0 would report success for a run whose outcome is unknown.` sentence from the NotProcessExit message. The comment asserted the exact property it violated, so the doc was the thing that lied rather than the code. Found by review 58567 on gunbc#9864. The sentence is restored rather than the claim weakened, because it carries the operator-facing reason the refusal exists: an unknown outcome reported as success is the fabricated plausible output §5 forbids, and that is the half a reader needs in order to not "fix" the refusal away. The doc is also corrected to state the STRONGER and true property. It claimed an equivalence held by care; main.rs holds no match of its own and delegates entirely, so there is ONE implementation and the two paths cannot disagree by construction. Saying "kept equivalent" understated it and simultaneously invited the drift it failed to prevent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQ4iThiZ1B9LPB9ePr8qa9 * Drop the empty_repository import that outlived its only consumer Review 58591 on #9864 found it. `scm_init` was the sole reference; removing init left the import standing. Verified: zero occurrences of `empty_repository` remain in the module. `empty_proposal` from the neighbouring import IS still used, by scm_status_response, so only the one line goes. Same class as the reviewer's own item 1 on this PR -- private artifacts that should have left with the capability they served. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQ4iThiZ1B9LPB9ePr8qa9 * Stop the plan and the source narrating init as landed behaviour The SCM reviewer's item 3, and they were right that I reported it as done when the pushed tree still said otherwise. Three separate false statements, each verified against the tree before changing it: 1. docs/plans/scm-demo-cli-rebuild.md carried an `### init -- the write verb` section under the landed material, with four execution receipts, reading as behaviour this PR ships. It is now explicitly headed DEFERRED, states that nothing in it may be read as evidence for this change, and says why init was cut rather than fixed in place (the observe-then-write TOCTOU, and that closing it needs a new modeled file-transport verb in a load-bearing stage). 2. The same doc claimed the init refusal claims were "hermetically enrolled" in test.claim.scm.scm_cli_witness. They are not: that witness has ZERO init claims on this branch -- they left with init. Corrected to say so, and to say where they went. 3. The "next increment" section derived "a host WRITE is permitted" from `scm_init` created a file, presented as a settled fact of this PR. The fact survives -- it is a fact about the HOST, not about init's safety -- but it is now carried as prototype evidence rather than as something landing here. Also two bounded text corrections the reviewer named: - cli_wire_host_admission said "the three declarations below are the route". Five production declarations sit below. Verified from the roster's own decl_name rows: CliWireClass and CliWireOutcome are carriers, classify_cli_wire / cli_wire_outcome / exit_status_for_class are the three functions. The text now makes that split explicit instead of undercounting to match the route. - gunbc.scm.cli said "every `gunbc scm` answer was computed and discarded". There is no `gunbc scm` subcommand -- the annotation named a route that does not exist two lines before naming the one that does. Now "every SCM read answer". The PR title had the same defect and is changed to name `gunbc run`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQ4iThiZ1B9LPB9ePr8qa9 * Scope the plan's time claims, and drop a write-renderer bullet with no write renderer Reviewer's native 5089156132 items 1 and 2 on #9864. 1. The PR body's "Named, not hidden" section still named a defect in the write renderer's handling of RepositoryEncodeRefusal arms. There is no write renderer in this PR -- it left with init for #10026. Bullet removed. 2. Three incompatible time claims in docs/plans/scm-demo-cli-rebuild.md: - It opened "It is a plan, not a receipt: nothing here claims to be built" and then carried a LANDED section full of execution receipts. Now states that it is a plan AND a historical ledger, separated by section, and that a present-tense statement in the plan half describes the BASELINE rather than the tree today. - It said in the present tense that scm_log_cli_response and scm_status_cli_response have NO consumer. This branch adds that consumer. Marked as the baseline gap this change closes, pointing at the receipts. - It presented `Commands::Scm` + `scm_verb` as the required host binding. That is NOT what landed, deliberately: binding CliWireResponse once in the generic run_verb outcome seam makes EVERY wire-returning entry reachable instead of only the scm family -- one binding rather than one per verb. Now marked as a future ergonomic surface rather than unfinished work required by the binding that shipped. The deferred-init subsection the reviewer accepted is untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQ4iThiZ1B9LPB9ePr8qa9 --------- Co-authored-by: gunbc-ci-auto-heal <gunbc-ci-auto-heal@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* Record the SCM demo CLI rebuild plan and its API inventory A working 'gunbc scm' CLI existed and was lost with an uncommitted /tmp worktree, along with the plan doc describing it. This commits the survey so the rebuild does not start by re-reading every scm module, and so the next context loss costs nothing. The useful finding from re-surveying: the gap is smaller and more specific than 'build a CLI'. gunbc.scm.render ALREADY reaches CliWireResponse via scm_log_cli_response and scm_status_cli_response -- they simply have no consumer outside dag/test/claim/scm/scm_render_witness_test.dag, which is the unwired-renderer state gunbc.cli_dispatch_surface already records. It also states why the corpus's idiomatic instrument shape cannot substitute for the host binding: 'fn check(...) -> ProcessExit' can only emit text on FAILURE (exit_failure's reason), and log/status must print on success. That is the argument for the main.rs work rather than a workaround, and it is the thing I would otherwise have had to re-derive. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wy8wRfzTK2kvFzj9nAEbyT * Host reader and pure outcome map for CliWireResponse gunbc.scm.render already produces CliWireResponse (scm_log_cli_response, scm_status_cli_response) and nothing outside its witness test consumes it -- the answer is computed and discarded, which is the unwired-renderer state gunbc.cli_dispatch_surface records. This adds the two host-side pieces that binding needs. classify_cli_wire sits beside classify_exit as the single authority for reading the variant shape, so the driver seam cannot fork it. A missing or wrongly-shaped field is NotCliWire rather than a default: defaulting bytes to "" would print nothing and exit 0, which is a fabricated plausible output. cli_wire_outcome is the total, pure map from that class to what the host does -- bytes, status, message. Pure for the reason exit_status_for records about itself: the inlined version of that decision dropped a case and reported success for every failure. It lives in the LIB, not beside the driver, because main.rs is a BIN target and the rust-unit-tests job runs 'cargo test -p v1-compiler --lib' -- a test written next to the driver is compiled by clippy and executed by nobody. exit_status_for_class is shared by both paths so the wire path and the plain ProcessExit path cannot disagree about what a verdict means. NotCliWire yields None rather than a failure, so the driver falls through to classify_exit and every existing ProcessExit entry behaves exactly as before; a test pins that. 5 executing witnesses: bytes written with the response's own exit, a rendered answer that still fails, the renderer's refusal not reading as an empty success, the fall-through, and the inherited ExitFailure{code:0} refusal. NOT YET WIRED into run_verb -- that is the next commit, and until it lands this reader has no production consumer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wy8wRfzTK2kvFzj9nAEbyT * Wire CliWireResponse into run_verb: the SCM answer reaches an operator gunbc.scm.render has produced CliWireResponse since it was written, and outside its witness test nothing consumed it. A function returning one hit classify_exit's NotProcessExit arm and the run refused with 'wrap the result in ExitSuccess / ExitFailure', so every gunbc scm answer was computed and discarded -- the unwired-renderer state gunbc.cli_dispatch_surface records. run_verb now tries cli_wire_outcome first and falls through to classify_exit unchanged for every other value. Binding it in the OUTCOME SEAM rather than under a new 'scm' subcommand keeps dispatch peripheral (§3): one binding serves every wire-returning entry instead of the host growing an arm per verb. dag/gunbc/scm/cli.dag is the entry the host invokes -- scm_log and scm_status, composing the read side onto a declared plain-terminal capability. The capability is declared, not detected: a host that learns to report a real one passes it in. PROVEN BY EXECUTION, not by typecheck: gunbc run --entry dag/gunbc/scm/cli.dag --function scm_log --arg path=/tmp/no-such-repo cannot read repository at /tmp/no-such-repo No such file or directory (os error 2) repository unavailable EXIT=1 That is the renderer's own document on stdout AND a nonzero exit -- the 'printable response carrying a failing exit' case, which is the one an absorbing implementation would have turned into either silence or a spurious 0. Also dissolves a fork I had just created: main.rs's exit_status_for carried its own copy of the verdict map, and once the wire path needed the same decision that copy was two places free to drift. The body now lives in cli_run::exit_status_for_class, where an executing test can reach it -- rust-unit-tests runs 'cargo test --lib', and main.rs is a bin whose tests are compiled by clippy and run by nobody. clippy --all-targets -D warnings: clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wy8wRfzTK2kvFzj9nAEbyT * Record the working read surface and state its evidence boundary Receipts for the three executed cases, including the load-bearing one (bytes printed AND a nonzero exit), so the next session does not re-derive them. States the rung honestly rather than implying the e2e is covered: the five cli_wire_outcome tests execute in CI, and the end-to-end runs are a MANUAL receipt. An integration test would live in src/v1/tests/, which clippy compiles and no CI step runs; a .dag witness cannot substitute because scm_log reads a file and the SCM witnesses are SubstrateInputsOnly. So the e2e path is mitigatable and its next-rung trigger is a CI step that runs an integration target -- not another test file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wy8wRfzTK2kvFzj9nAEbyT * Make gunbc.scm.cli's own decisions witnessable, and enroll them gunbc.scm.render is already witnessed against PlainTerminal and an ASCII no-colour capability. Nothing held gunbc.scm.cli to passing THOSE: setting color: true on plain_terminal_capability would have turned no claim red, which is the inert-check shape DESIGN calls worse than absent. The composition is split from the read -- scm_log_response / scm_status_response take the read's RESULT and are pure, while scm_log / scm_status supply it from a path. That split is what makes the claims authorable at all: the verbs read a file and this witness family is SubstrateInputsOnly, so a claim can never call them. Five enrolled claims: the unavailable-repository bytes (pinned to the same expected string the render-level witness uses, so an equal answer is evidence the PLAIN target and capability were passed, not merely that something was), its failing exit, the empty-log answer with a succeeding exit, the same for status, and the capability row itself so a reader editing it learns it is a contract. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wy8wRfzTK2kvFzj9nAEbyT * scm init: the first write verb, and a real round trip gunbc run --entry dag/gunbc/scm/cli.dag --function scm_init --arg path=/tmp/demo-repo.json initialized repository at /tmp/demo-repo.json rc=0, file created ... --function scm_log --arg path=/tmp/demo-repo.json no commits yet init writes the document, log reads it back. That settles the open question the plan doc recorded: a host WRITE is permitted from gunbc run, so the remaining write verbs are a modeling question rather than a permissions one. render.dag gains the write-answer renderer. A write verb answers with what the write DID, and its four outcomes are not one line with a flag -- each names a different failure and a different next action. RepositoryWriteByteCountUnrepresentable EXITS FAILURE even though bytes reached the disk: a write whose reported size is not a magnitude has not been confirmed, and reporting success for it is the fabricated plausible output §5 forbids. TWO LOSSES NAMED IN THE SOURCE RATHER THAN HIDDEN: - The codec refusal carries a typed RepositoryEncodeRefusal with six arms (uncontained target, root not in store, checkout not in commits, duplicated reference, reference outside allocator, parent not in commits) and this renderer does not decompose it, so an operator learns THAT encoding refused and not WHICH invariant failed. Rendering it needs a per-arm function over ObjectId and RepositoryCommitRef. - init does NOT refuse an existing repository. save_repository writes unconditionally, so init over a populated path overwrites it. Refusing needs a read-before-write that is not expressible as one outcome here. That is why this verb is not offered as a safe default. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wy8wRfzTK2kvFzj9nAEbyT * Record what add and commit need, including the one design step Settles by execution what was open: a host write IS permitted from gunbc run, content enters via store_node, a Node is built with node_synthetic (the test modules' 'atom' is a LOCAL helper, not an authority), and mint_repository_commit requires store_contains(root), so add must precede commit -- and because identity is content-derived, commit can re-derive an added object's ObjectId by rebuilding the same node. Names the design step rather than sketching it: add composes store_node's 2 arms with save_repository's 4, and commit composes mint's 4 with the same save. A renderer taking two outcome values would encode 'which one failed' positionally and the arms multiply. One modeled ScmWriteOutcome is the increment's real content. I deliberately did not improvise it into cli.dag at the end of a long session, because a write-verb outcome invented at a call site is the anemic modeling this repository keeps paying for. Also records that 'add' has no staging authority to persist to -- repository_status takes pending as a PARAMETER because what is staged is not a fact the document carries -- so a literal stage-now-commit-later add needs a staging authority to exist first. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wy8wRfzTK2kvFzj9nAEbyT * init refuses an occupied path: naming a destructive default did not make it fail-closed Review 58044 was right and this was the worst thing in the PR. save_repository writes unconditionally -- correctly, it is persistence and not policy -- so a verb offering initialization has to decide for itself whether there is anything at the path it would destroy. The previous revision skipped that decision, called save directly, and NAMED the overwrite in a comment. DESIGN §5's review bar is that a diff landing a non-fail-closed failure arm is a hard reject regardless of what else it delivers; an annotation is not a refusal. gunbc.scm.init owns the decision, because whether there is anything to destroy is a fact about the repository and deciding it in the CLI module would put policy in the realization layer. ScmInitOutcome has three arms and no 'initialized: Bool' beside a save outcome -- a value whose flag disagreed with its shape would have no spelling -- and the two refusals are distinct because the operator's next action differs: a path that already IS a repository is not the same as a path holding someone else's bytes. MEASURED, including the case that proves the destruction is actually closed: fresh path -> initialized repository at /tmp/d2.json same path again -> refusing to initialize /tmp/d2.json a repository is already there, and init would replace its whole history foreign file -> refusing to initialize /tmp/foreign.json a file is already there that is not a repository, and init would destroy it foreign file after-> 'not a repo' (intact -- read back, not assumed) THE RESIDUAL IS STATED IN THE MODULE RATHER THAN IMPLIED. RepositoryFileUnreadable fuses 'absent' with 'present but unreadable' and carries the host's error STRING. This module refuses to branch on that text, because deciding a destructive question by matching an errno spelling is the stringly reasoning the substrate exists to remove. So the proceed arm is 'unreadable', not 'absent'. Closing it needs a modeled path-existence observation distinct from a read failure, which does not exist yet and is the module's next-rung trigger. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wy8wRfzTK2kvFzj9nAEbyT * Make scm init proceed only from an established absence, and receipt the wire host Rust Three blocking findings from review 58060 on #9864. 1. `init` was fail-open on `RepositoryFileUnreadable`. Proceeding there is a guess about the host's permission model, not an observation -- a file can be unreadable and perfectly truncatable, so the proceed arm could destroy the bytes it existed to protect. The residual was NAMED in the module header, and a named residual does not refuse. `initialize_repository` now takes a DIRECTORY and a NAME and routes through `extdeps.filesystem.filesystem_io` `filesystem_file_observation`: absence is established from a listing that succeeded and did not name the entry, and only that arm reaches `save_repository`. Present bytes are classified (repository vs foreign) for the operator's benefit but both refuse; indeterminate and disagree refuse as `ScmInitRefusedPathUnobserved`, carrying the host's cause. Executed, four cases: fresh directory writes 166 bytes; an existing repository refuses; a foreign file refuses and reads back byte-intact; a directory with mode 000 refuses naming `Permission denied (os error 13)` rather than widening to "nothing is there". Three hermetic claims enrolled beside them in test.claim.scm.scm_cli_witness -- no refusal renders as a success exit, the three refusals render differently, and the unobserved arm names what could not be observed. 2. The hand-authored Rust had no seed-growth receipt. `gunbc.cli_wire_host_admission` enumerates all 11 declarations at identity grain, states why the host process and the interpreter Value both lack a .dag denotation today, records that `exit_status_for_class` is a relocation rather than new behaviour, and names two capability-grain triggers. It does NOT admit the growth; the disposition is Terminal and fails closed without an operator ruling. Wired into `gunbc.seed_growth_admission`. 3. `gunbc.cli_dispatch_surface` still asserted there is NO route a user can invoke for the scm family, which this branch made false. The row now names the real route and says what changed, and its stage0 mirror is regenerated from the emitter (the candidate tree differed from the committed mirror by exactly this one string). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wy8wRfzTK2kvFzj9nAEbyT * Regenerate the Rust source-type bindings from their authority main carries an updated `proof` row in gunbc.rust_source_type_bindings whose committed projection was never regenerated, so origin/main is itself in a drifted state and every branch that merges it inherits a failing regen phase. Measured here rather than assumed: both the .dag authority and the .rs projection on this branch are byte-identical to origin/main, and regen still reports first_generation_equal=false for this one file. The fix is the projection, not the authority: the generated file now carries what the row says. The build lane is green at this head -- regen first_generation_equal=true, generated-artifact 35/35 matched, 0 drifted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQ4iThiZ1B9LPB9ePr8qa9 * The relocation note was prose in a String row, which §4c quarantines cli_wire_host_relocation_note was a `data ... : String` whose only purpose was commentary, with no consumer -- the neighbouring SeedGrowthJustification did not reference the symbol, it referenced the NAME inside its own prose. That is the state DESIGN §4c names: an ordinary String declaration carrying commentary is mechanically indistinguishable from program data, and `//` is the quarantine boundary that keeps the two apart. It is authored rationale about why the roster counts a relocation, so it becomes an annotation on the declaration it explains. The FACT it carried does not disappear with it: that exit_status_for_class is a relocation rather than new behaviour is now stated inside the justification's own `reason`, where a consumer reading the receipt sees it, rather than in a sibling row a consumer would have to know to look up. Found by review 58455 on gunbc#9864. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQ4iThiZ1B9LPB9ePr8qa9 * Admit the entry name, and make the init decision witnessable Two of the four findings in review 5085479276 on gunbc#9864. THE NAME WAS USED TWICE AND GUARANTEED ONCE. initialize_repository asked the listing about `name` and separately joined directory + "/" + name into a path, with nothing making those the same subject. `subdir/repo.json` is asked about as a child of the listed directory while the join reaches past it, `../victim` leaves it entirely, `.`/`..`/`""` name no child at all, and -- the case that belongs to the filesystem module rather than to any caller -- a name carrying a newline can make the membership test answer yes for an entry that is not there, because newline is Filesystem.List's own delimiter. FilesystemEntryName is sole_constructor, so admission is unavoidable rather than advisory, and the refusal names which rule rejected the spelling. Its scope is stated honestly: it is adopted at this consumer, filesystem_entry_presence still takes a String, and widening that is a replacement migration over the whole population with its own next-rung trigger -- not smuggled into the change that introduces the carrier. THE DECISION WAS NOT WITNESSED, ONLY ITS RENDERING. Every enrolled init claim built a refusal outcome and checked how it printed, so nothing executed the step that CHOOSES an outcome from an observation; a mutation routing indeterminate to the write arm left the family green. scm_init_decision is that step with the two operations lifted out, and the four arms are now driven one fact apart through the REAL fold -- FilesystemEstablishedAbsence being sole_constructor means a witness cannot hand in a fabricated absence, which forces the evidence to cover filesystem_file_observation and the decision together. Executed: routing indeterminate to a present-refusal takes an_unobservable_path_does_not_authorize_a_write red; routing a real absence away from the write arm takes only_an_established_absence_may_create red. The third mutation -- routing DISAGREEMENT to the write arm -- is unwritable, because ScmInitMayCreate carries the sole_constructor absence and no arm can manufacture one. The rung is split on that boundary rather than averaged. The refusal vocabulary is one type (ScmInitRefusal) consumed by both the decision and the outcome, so a refusal has one spelling rather than two, and the outcome never carries an arm that is only ever intermediate. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQ4iThiZ1B9LPB9ePr8qa9 * A malformed wire response is not "some other type" Third of the four findings in review 5085479276 on gunbc#9864. classify_cli_wire returned NotCliWire for two materially different situations: a value of some other type, and a value that NAMES CliWireResponse but does not inhabit it. cli_wire_outcome answers None for NotCliWire so the caller falls through to classify_exit, which meant a malformed wire response was reported as error: function `f` returned `...`, not `ProcessExit` -- true, useless, and about a type the value never claimed. It hides that the value claimed a type it does not inhabit, and it sends the caller to the wrong remedy: wrap this in ExitSuccess, when the actual defect is a missing `bytes`. MalformedCliWire is its own arm and REFUSES rather than falling through, exiting 2 like the other shape refusal. NotCliWire keeps its fall-through unchanged, which is what preserves every pre-existing ProcessExit entry point. THE TESTS THE REVIEW SAID DID NOT EXIST. All five original tests started from an already-classified CliWireClass, so classify_cli_wire had no coverage at all -- the boundary carrying the defect was the one nothing executed. Seven tests now build real interpreter values: a positive control that a well-formed printable still classifies as printable, a control that a plain ProcessExit and a bare string still fall through (the case that must NOT become malformed, or every existing entry point breaks), the four malformed shapes, and one that the refusal reaches the host with status 2 and no stdout. Executed: restoring the fall-through for MalformedCliWire takes a_malformed_wire_response_refuses_rather_than_falling_through red and leaves the other twelve green, so the arm is load-bearing for exactly the reported case. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQ4iThiZ1B9LPB9ePr8qa9 * The seed-growth receipt undercounted the module I had just added Adding cli_wire_classify_tests put thirteen hand-authored declarations into cli_run.rs that the receipt did not name, while it went on asserting "+11 FROM THE MERGE BASE, ENUMERATED ABOVE". A roster whose subject is hand-authored declarations, silently missing thirteen of them, is the defect it exists to prevent -- and it was introduced by the change that fixed a different one. Enumerated at 25 and recomputed: 449 insertions in cli_run.rs, 84 changed lines in main.rs, re-derived against origin/main rather than a stale local `main` ref. The five test HELPERS are counted, not just the seven test fns. The roster's subject is declarations, and a helper is one; waving them through as "just tests" is the same netting this receipt already refuses for the relocated exit_status_for_class. Two further .rs files differ and the row says WHY they are outside the subject rather than omitting them: gunbc_cli_dispatch_surface.rs and gunbc_rust_source_type_bindings.rs are generated projections regenerated from their .dag authority, so they are excluded by construction, not by exemption. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQ4iThiZ1B9LPB9ePr8qa9 * Close the init TOCTOU with a create-only write, not a better check Review 5085479276 on gunbc#9864, finding 1: initialize_repository established an absence from a directory listing and then called save_repository, which writes UNCONDITIONALLY. The decision was honest and the write was simply not conditioned on it, so an actor creating the file between the two made init truncate exactly the bytes it advertises that it refuses to touch. THIS IS NOT REPAIRABLE BY OBSERVING MORE CAREFULLY. Check-then-write is two acts with a gap, and re-observing only makes the gap smaller. The existence test and the creation have to BE one act, which is a property of open(2) with O_CREAT|O_EXCL and not something a fold over two modeled operations can express. Construction over validation at a boundary where validation structurally cannot win. So the substrate gains the operation rather than the caller gaining a check: extdeps.filesystem.filesystem_io WriteCreateNew, a FileWriteCreateNew verb in the emit stage, and one hand-authored realization. gunbc.scm.repository_save gains create_repository beside save_repository -- persisting a repository that exists and creating one that must not exist are different subjects, and the unconditional write stays correct for the first. NOT WriteOwnerOnly, WHICH ALREADY CALLS create_new. Its O_EXCL is incidental to setting a mode at creation -- meaningless on a path that already exists -- and is not a contract. Owner-only MODE and create-only EXISTENCE are independent facts; a caller taking create-only from it would silently also take 0600 and would break the day owner-only stopped needing O_EXCL. Reusing a realization detail in place of a modeled fact is the inversion §3 names, and the review rejected it explicitly before this landed. THE REFUSAL DOES NOT CLASSIFY ITSELF, and that is deliberate. It carries the host's error verbatim and does not report whether the cause was "already existed" or "permission denied": the transport's channels cannot separate them, and deciding it by matching the error TEXT would be a heuristic standing in for an observation. The caller learns what it needs in order to refuse and does not learn a classification nothing measured. That gap has its own next-rung trigger. Executed, and the mutation is the original defect rather than an invented one: replacing create_new with create+truncate -- what the code did before -- takes create_new_refuses_a_path_that_already_exists_and_leaves_its_bytes red while the positive control stays green. The load-bearing assertion is that the EXISTING BYTES SURVIVE, not that an error is returned: a write that truncated and then reported failure would satisfy a weaker test and still have destroyed the file. Seed growth is receipted in gunbc.filesystem_create_new_admission and rostered, with a trigger naming the capability (creation exclusivity declarable as a property of a write, with the realization deriving the flags) rather than an artifact. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQ4iThiZ1B9LPB9ePr8qa9 * Move the create-only rationale to module-item grain, and fix its imports Two defects my own build lane caught rather than a reviewer. The WriteCreateNew annotation sat INSIDE the service body, which §4c refuses: only module-item grain is modeled, and an operation lives inside a service's declaration. It now sits above the service that contains it, and says why it is there rather than on the operation it describes -- so the next author does not repeat the move. The seed-growth row imported DeclarationRef and WholeDeclaration from gunbc.seed_growth, which does not export them; std.decl_ref does. Build lane green: regen first_generation_equal=true, generated-artifact 35/35, 0 drifted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQ4iThiZ1B9LPB9ePr8qa9 * Four of review 5089156132's six findings on the init model #10026 landed with these open. Each was verified against the code before being accepted, and each carries a control that goes red on the defect. 1. ScmInitialized could contain a FAILED creation. It carried the whole RepositorySave, so `ScmInitialized { save: RepositoryFileUnwritable { .. } }` was spellable and told every consumer matching the outer arm that initialization happened. The renderer noticed and exited nonzero, which repaired the message and not the carrier. ScmInitialized now carries only { path, written } from a successful create; init_outcome_of_save is a total fold over all four save arms and every other arm reaches ScmInitWriteRefused. 2. The established-absence authority was discarded before actuation. The code matched ScmInitMayCreate(_) and wrote to an independently supplied path, so an absence established for one subject could sit beside a create against another and no mutation to the write target went red. The token carries { directory, name }, so create_repository_at_absence now DERIVES the target from it -- there is no second target to disagree with. The rung prose is corrected rather than softened. Four external reviews read the sole_constructor and endorsed a rung-4 authorization claim; the designated reviewer read the MATCH and found the token was never consumed. A capability carried past its actuation authorizes nothing. The annotation now splits the rung honestly and says plainly that no preflight closes a TOCTOU -- O_EXCL does -- so the observation decides what the operator is told and which subject may be written, not whether the race exists. 3. WriteCreateNew erased the post-create write-failure state. open-then-write leaves a created, zero-or-partial file if write_all fails, while the model reports the create did not happen. That is worse than the overwrite race this operation closed: the race could destroy someone else's bytes, this FABRICATES a repository nobody wrote. Fixed by construction rather than by modeling two dispositions -- content is staged to an O_EXCL sibling and the target name is claimed with hard_link, which fails if the target exists, so exclusivity moves to the publish step and every earlier failure leaves the target absent. THE FIRST CONTROL I WROTE FOR THIS WAS A DECORATION, and the dead attempt is documented in the test module so it is not rebuilt. It put a DIRECTORY at the target; measured against the old construction it PASSED, because create_new fails at the OPEN when the name exists so nothing was ever created. The replacement injects RLIMIT_FSIZE in a forked child so the create succeeds and the write fails -- the one ordering that separates the constructions. Proven both ways: green on the fix, RED on the pre-fix construction with "a write that failed after creation must leave NO target behind". 4. FilesystemEntryName rejected `/` but admitted backslash and NUL while WriteCreateNew is implemented on non-Unix. `..\victim` is a parent traversal there, and a NUL makes the syscall see a PREFIX of the name the listing was asked about. The grammar is the UNION over supported targets, not the one the author happens to run on. Still open from that review: finding 5 (the emitted realization in 05_emit_rust.dag is independently mutable, plus two weak assertions) and finding 6 (compose #9864's final authority, which must land first). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQ4iThiZ1B9LPB9ePr8qa9 * The other two parts of finding 5: pin the valid repository, and enrol the fourth refusal 5.2 -- present_bytes_do_not_authorize_a_write_and_are_classified asserted only `!= may_create` for a DECODABLE repository. That is satisfied by mapping every present file, valid repository included, to ScmInitForeignFilePresent -- which would tell an operator a foreign file is in the way of their own repository, and the claim would have stayed green. The two present arms are one fact apart (same listing and read, differing only in whether the content decodes), so each is now pinned to its own arm. That is the difference between classification evidence and a not-the-write-arm check. 5.3 -- ScmInitNameNotAnEntry became a fourth refusal arm while both renderer claims still enumerated three. The direct admission test proved the name refuses EARLY; nothing proved it reaches an operator as its own answer with a failing exit. Both claims now cover four arms, and the claim is RENAMED to the_four_init_refusals_render_differently so its name moves with its population -- a claim that names a population and does not move silently covers a shrinking fraction of its subject. Not done, and deliberately not guessed at: 5.1, the emitted realization in 05_emit_rust.dag being independently mutable to create/truncate while the Rust unit tests stay green. That is the same class as the decoration I shipped and withdrew earlier in this branch, one layer out, and it wants either an executing emitted-program probe or a construction that prevents the two realizations from drifting. I have asked the reviewer which shape they want rather than build the wrong one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQ4iThiZ1B9LPB9ePr8qa9 * Finding 5.1: the emitted realization kept the defect the interpreter fix removed Review 58797 on gunbc#10069 is correct, and it is a sharper statement than my own framing of the same row. I had called this "the emitted realization is independently mutable" -- a testing gap. It is not: the DEFECT ITSELF was still present. v1_interpreter::write_file_create_new was repaired to stage-then-link while src/v1/05_emit_rust.dag's file_write_create_new_expr still spelled open-then-write against the target, so a failed emitted write could still leave a partial repository while reporting failure. The fabricated-repository failure survived, in the realization my tests do not execute. That is one fact with two authorities (DESIGN sections 2 and 3), where repairing the reachable one leaves the other lying; and it is section 4b rung honesty, because a class's rung is the MINIMUM across its paths and a fix on the interpreted path does not raise the emitted one. The emitted expression now uses the same construction: stage to an O_EXCL sibling, publish by hard_link (which fails if the target exists), remove the staging file on every path. Projection regenerated. EVIDENCE, over the EMITTED BYTES rather than the interpreter. The expression is decoded out of 05_emit_rust.dag, compiled as a standalone program, and executed: an absent path is created with the right content; an existing path REFUSES and its bytes survive; no staging temporary is left behind. Proven discriminating by the mutation the review itself named -- replacing the body with create/truncate turns the probe RED at "an existing path must refuse". WHAT THIS DOES NOT CLOSE, stated rather than left to be rediscovered: these are still two spellings. An emitted standalone program cannot call the seed's helper, so nothing but review holds them in step, and the drift this review caught can recur. The note in 05_emit_rust.dag carries a next-rung trigger naming the CAPABILITY -- one file-transport realization authority that both the seed and the emitted program consume -- rather than an artifact that would merely contribute to one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQ4iThiZ1B9LPB9ePr8qa9 * Derive the valid-repository fixture from the writer, not a hand-authored literal The floor red on 78a5220 was my own tightened claim: I had pinned the valid-repository arm of present_bytes_do_not_authorize_a_write_and_are_classified from `!= "may_create"` to `== "repository_present"`, and the hand-authored payload `{"format":"gunbc-scm-repository-v2"}` does not decode as a v2 repository, so the classifier answered `foreign_present`. Reverting the pin would re-weaken the claim to one that cannot tell a repository from a foreign file, so the fixture is what changes. A literal here is a second authority for the repository wire format (DESIGN section 3): it agrees with the codec only while someone keeps retyping it, and the version it drifts to is silently classified as a foreign file -- the classifier reporting a repository this program just wrote as not one. The fixture is now `serialize_json` of `encode_repository_checked(empty_repository())`, the same two calls repository_save makes, so writer and classifier are held to agreeing BY EXECUTION. The encode-refused arm yields "", which decodes as foreign rather than as a repository, so a refusal fails this claim instead of vacuously satisfying it. Mutation-proven discriminating: substituting the old literal back into saved_repository_bytes turns the claim RED; the derived bytes turn it green. All 20 claims in test.claim.scm.scm_cli_witness pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQ4iThiZ1B9LPB9ePr8qa9 * Spell the staging open with `?`, which the emitted realization already did The composed head's rust-unit-tests job failed on clippy::question_mark (the repo clippy command runs with -D warnings): write_file_create_new opened its staging file through `match { Ok(f) => f, Err(e) => return Err(e) }` where `?` is exact. Clippy is right and this is not a lint to allow. That arm creates nothing, so there is no staging file to clean up -- which is precisely why it is the one failure path in this function that does NOT remove_file, and the comment now says so rather than leaving the `?` looking careless next to three neighbours that do clean up. It also removes a difference that was never semantic: file_write_create_new_expr already spelled this `?`, so the two realizations disagreed on the spelling of one line while agreeing on its meaning. One less thing for the commissioned single-authority cut to reconcile. Verified with the exact CI command rather than by inspection: cargo clippy --all-targets -- -D warnings clean cargo test --release -p v1-compiler --lib write_file_create_new create_new_writes_when_nothing_is_there ok create_new_refuses_a_path_that_already_exists_and_leaves_its_bytes ok a_write_failure_after_creation_leaves_no_target_behind ok Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQ4iThiZ1B9LPB9ePr8qa9 --------- Co-authored-by: gunbc-ci-auto-heal <gunbc-ci-auto-heal@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Closes review 5085479276's finding 1 on #9864 — the one finding that could not be fixed inside that PR, because the fix needs a new file-transport verb in a load-bearing pipeline stage. The operator ruled it into its own PR so the emit-stage change is judged on its own merits.
The defect
initialize_repositoryestablished an absence from a directory listing and then calledsave_repository, which writes unconditionally. The decision was honest; the write was simply not conditioned on it. An actor creating the file between the two makes init truncate exactly the bytes it advertises that it refuses to touch.Why a better check cannot fix it
Check-then-write is two acts with a gap, and re-observing only makes the gap smaller. The existence test and the creation have to be one act — which is a property of
open(2)withO_CREAT|O_EXCL, not something a fold over two modeled operations can express. So the substrate gains the operation rather than the caller gaining a check. Construction over validation (§5) at a boundary where validation structurally cannot win.extdeps.filesystem.filesystem_io—WriteCreateNewsrc/v1/05_emit.dag/05_emit_rust.dag—FileWriteCreateNewverbv1_interpreter— one hand-authoredwrite_file_create_newgunbc.scm.repository_save—create_repositorybesidesave_repository. Persisting a repository that exists and creating one that must not exist are different subjects; the unconditional write stays correct for the first.Not
WriteOwnerOnly, which already callscreate_newIts
O_EXCLis incidental to setting a mode at creation — meaningless on a path that already exists — and is not a contract. Owner-only mode and create-only existence are independent facts. A caller taking create-only from it would silently also take0600, and would break the day owner-only stopped needingO_EXCL. Reusing a realization detail in place of a modeled fact is the inversion §3 names; the review rejected it explicitly.The refusal does not classify itself, on purpose
It carries the host's error verbatim and does not report whether the cause was "already existed" or "permission denied". The transport's channels cannot separate them, and deciding it by matching the error text would be a heuristic standing in for an observation (§5). The caller learns what it needs in order to refuse, and does not learn a classification nothing measured. That gap carries its own next-rung trigger rather than being papered over.
Evidence
The mutation is the original defect, not an invented one: replacing
create_newwithcreate+truncate— literally what the code did before — takes the refusal test red while the positive control stays green.create_new_refuses_a_path_that_already_exists_and_leaves_its_bytescreate_new_writes_when_nothing_is_thereThe load-bearing assertion is that the existing bytes survive, not that an error is returned. A write that truncated and then reported failure would satisfy a weaker test and still have destroyed the file — that distinction is the whole finding.
Also re-lands, on top of the primitive, the work cut from #9864 because it had no other consumer: the
FilesystemEntryNameadmission carrier and the four init-decision witnesses.Seed growth is receipted in
gunbc.filesystem_create_new_admissionand rostered, with a trigger naming the capability (creation exclusivity declarable as a property of a write, with the realization deriving the flags) rather than an artifact.🤖 Generated with Claude Code
https://claude.ai/code/session_01VQ4iThiZ1B9LPB9ePr8qa9