ci(gates): enforce that the nine gates leave the shared tree alone (#1253) #1332

Merged
bosun merged 2 commits from i/1253-gates-share-a-workspace into main 2026-09-06 15:21:19 +02:00
Owner

Closes the residual I flagged on #1320 and named there as the weakest link in #1253. Not dispatched — I requested a tracker from @bosun and started, because this is a live hazard in code that shipped today, against #1304's hypothetical ~10s.

What consolidation quietly changed

BEFORE   five gates, five checkouts, five $RUNNER_TEMP    a dirty tree cost NOTHING
AFTER    nine gates, ONE checkout,  ONE  $RUNNER_TEMP     a dirty tree is handed to the next eight

A gate that leaves state behind now corrupts a sibling's verdict, and neither gate is wrong on its own — which is exactly why nothing in either gate's own controls can catch it.

That they do not collide today is an audit I performed at step 1, not a property anything enforced. Nothing reddened if a future gate started leaving state behind. Two guards replace the audit.

① Static — no two gate steps may claim the same temp path

$RUNNER_TEMP/rt is the one legitimate sharer; it is the consolidation. It is named in a SHARED_BY_DESIGN set rather than silently special-cased, so widening that set is a visible edit.

② Runtime — the tree must end as it began

Record git status --porcelain after the build, compare after the nine. Read state and compare — do not assert "clean." Asserting a predicted string would turn a legitimately dirty starting tree into a mystery instead of a mismatch.

⚠️ It abstains when any gate did not succeed, and that is the load-bearing design decision

A failing gate may die before its own cleanup runs, so dirt there is expected collateral and the job is already red for that cause. Refusing again would add a second red for one cause and train people to ignore this one.

all nine succeeded  + dirty tree   -> REFUSE. The dirt is attributable to a leak.
any gate failed or  + dirty tree   -> UNGRADED, exit 0. Not attributable, and the job
skipped                               is already failing on that gate.

That is the three-outcomes shape rather than a two-state check rounding "I cannot attribute this" into "fine" — or, worse, into a second red.

Scope, stated in the PASS output rather than left to be inferred

"This checks what is LEFT, not what was briefly true. A gate that mutates the tree, is observed by a later gate, and then restores it leaves no residue and passes here."

Order-dependent coupling between gates stays unenforced. I would rather say that in the passing message than let a green read as covering it.

Mutation verification

Four mutants, each reddening the arm that owns it, each naming what it broke:

mutant message
abstain branch removed dirt after a SKIPPED gate must abstain, not refuse
guard never refuses a dirty tree with all nine gates green was ACCEPTED — a leak reads as clean
tree guard loses always() not always()-guarded — it would skip after a red gate
two gates share a temp path $RUNNER_TEMP/ac-closure-fixture.log is written or read by ['g1', 'g7']

Both new arms execute the guard rather than grepping the YAML for it, per the #1257 precedent.

The guard this PR adds could itself skip silently — fixed in a8b5182a

Applying the PR's own thesis to the PR. The shared-tree guard carries if: always() && steps.treestate.outcome == 'success'. If that expression ever stops resolving it skips, and a skipped step is green — which is the exact failure the attest step was written for. Shipping a guard that can silently skip, inside a change about replacing an unenforced audit, would have reproduced the defect one level out.

It now carries id: treeguard and the attest step grades it alongside the nine. A guard that RAN and refused reports failure, which attest deliberately treats as ran — the refusal fails the job through the guard's own step, not through the attestation.

Mutation-verified: removing the wiring reddens the attest arm with gate step(s) ['treeguard'] are not wired into the attest step's env.

Two authoring defects getting there, both mine, both caught by a test rather than by reading

① The GT: env line landed in the TREE GUARD's env block instead of the attest step's. Both steps carry an identical G1..G9 block, and my single-replace hit the first occurrence. The result was a self-reference that always reads emptysteps.treeguard.outcome consumed by treeguard itself. It looked correct in the diff and was wrong in the tree; anchoring on the last occurrence fixed it.

② The arm then failed on a needle, not a defect. It asserted the literal string "all 9 gate steps ran" and I had reworded the pass message to name the tenth step. A behavioural arm that asserts prose breaks when the prose improves — which is a real cost of executing-arms over structural ones, and worth paying, but worth naming.

Neither was caught by reading the diff. Both were caught because a test that had been green went red.

A harness defect worth recording, because it nearly inverted the result

My first version of the runtime arm pointed RUNNER_TEMP at the same directory as the throwaway repo. The guard writes tree-after.txt into $RUNNER_TEMP and then runs git status — so its own scratch files showed up as untracked and every arm reported a dirty tree.

It was caught because the CLEAN arm went red, which it cannot legitimately do. A harness that fails only the arms it should fail is indistinguishable from a working one; this one failed the arm that proves the harness itself.

What this PR does NOT do

  • It does not enforce order-independence between gates — see the scope note. That would need each gate run in isolation, which is the thing consolidation removed on purpose.
  • It does not re-audit the gates' contents. The nine bodies are unchanged; only two steps are added around them.
  • It does not address #1304. That is measured and my recommendation there is close-as-won't-do, with the numbers on the tracker.

Full suite 118/118. All six local gates green before push — register, manifest, fragment, changelog-body, gitea-twin, bats.

🤖 Generated with Claude Code

https://claude.ai/code/session_01MMmaXmMhZdAAnttWBS6zqa

Closes the residual I flagged on `#1320` and named there as the weakest link in `#1253`. Not dispatched — I requested a tracker from @bosun and started, because this is a live hazard in code that shipped today, against `#1304`'s hypothetical ~10s. ## What consolidation quietly changed ``` BEFORE five gates, five checkouts, five $RUNNER_TEMP a dirty tree cost NOTHING AFTER nine gates, ONE checkout, ONE $RUNNER_TEMP a dirty tree is handed to the next eight ``` A gate that leaves state behind now corrupts a **sibling's** verdict, and neither gate is wrong on its own — which is exactly why nothing in either gate's own controls can catch it. **That they do not collide today is an audit I performed at step 1, not a property anything enforced.** Nothing reddened if a future gate started leaving state behind. Two guards replace the audit. ## ① Static — no two gate steps may claim the same temp path `$RUNNER_TEMP/rt` is the one legitimate sharer; it *is* the consolidation. It is named in a `SHARED_BY_DESIGN` set rather than silently special-cased, so widening that set is a visible edit. ## ② Runtime — the tree must end as it began Record `git status --porcelain` after the build, compare after the nine. **Read state and compare — do not assert "clean."** Asserting a predicted string would turn a legitimately dirty starting tree into a mystery instead of a mismatch. ### ⚠️ It abstains when any gate did not succeed, and that is the load-bearing design decision A failing gate may die before its own cleanup runs, so dirt there is **expected collateral** and the job is already red for that cause. Refusing again would add a second red for one cause and train people to ignore this one. ``` all nine succeeded + dirty tree -> REFUSE. The dirt is attributable to a leak. any gate failed or + dirty tree -> UNGRADED, exit 0. Not attributable, and the job skipped is already failing on that gate. ``` That is the three-outcomes shape rather than a two-state check rounding *"I cannot attribute this"* into *"fine"* — or, worse, into a second red. ## Scope, stated in the PASS output rather than left to be inferred > *"This checks what is LEFT, not what was briefly true. A gate that mutates the tree, is observed by a later gate, and then restores it leaves no residue and passes here."* **Order-dependent coupling between gates stays unenforced.** I would rather say that in the passing message than let a green read as covering it. ## Mutation verification Four mutants, each reddening the arm that owns it, each naming what it broke: | mutant | message | |---|---| | abstain branch removed | `dirt after a SKIPPED gate must abstain, not refuse` | | guard never refuses | `a dirty tree with all nine gates green was ACCEPTED — a leak reads as clean` | | tree guard loses `always()` | `not always()-guarded — it would skip after a red gate` | | two gates share a temp path | `$RUNNER_TEMP/ac-closure-fixture.log is written or read by ['g1', 'g7']` | Both new arms **execute** the guard rather than grepping the YAML for it, per the `#1257` precedent. ## The guard this PR adds could itself skip silently — fixed in `a8b5182a` Applying the PR's own thesis to the PR. The shared-tree guard carries `if: always() && steps.treestate.outcome == 'success'`. If that expression ever stops resolving it **skips, and a skipped step is green** — which is the exact failure the attest step was written for. Shipping a guard that can silently skip, inside a change about replacing an unenforced audit, would have reproduced the defect one level out. It now carries `id: treeguard` and the attest step grades it alongside the nine. A guard that RAN and refused reports `failure`, which attest deliberately treats as *ran* — the refusal fails the job through the guard's own step, not through the attestation. Mutation-verified: removing the wiring reddens the attest arm with `gate step(s) ['treeguard'] are not wired into the attest step's env`. ### Two authoring defects getting there, both mine, both caught by a test rather than by reading **① The `GT:` env line landed in the TREE GUARD's env block instead of the attest step's.** Both steps carry an identical `G1..G9` block, and my single-replace hit the first occurrence. The result was a **self-reference that always reads empty** — `steps.treeguard.outcome` consumed by `treeguard` itself. It looked correct in the diff and was wrong in the tree; anchoring on the *last* occurrence fixed it. **② The arm then failed on a needle, not a defect.** It asserted the literal string `"all 9 gate steps ran"` and I had reworded the pass message to name the tenth step. **A behavioural arm that asserts prose breaks when the prose improves** — which is a real cost of executing-arms over structural ones, and worth paying, but worth naming. Neither was caught by reading the diff. Both were caught because a test that had been green went red. ## A harness defect worth recording, because it nearly inverted the result My first version of the runtime arm pointed `RUNNER_TEMP` at the same directory as the throwaway repo. The guard writes `tree-after.txt` into `$RUNNER_TEMP` and then runs `git status` — so its own scratch files showed up as untracked and **every** arm reported a dirty tree. **It was caught because the CLEAN arm went red, which it cannot legitimately do.** A harness that fails only the arms it should fail is indistinguishable from a working one; this one failed the arm that proves the harness itself. ## What this PR does NOT do - **It does not enforce order-independence between gates** — see the scope note. That would need each gate run in isolation, which is the thing consolidation removed on purpose. - **It does not re-audit the gates' contents.** The nine bodies are unchanged; only two steps are added around them. - **It does not address `#1304`.** That is measured and my recommendation there is close-as-won't-do, with the numbers on the tracker. Full suite **118/118**. All six local gates green before push — register, manifest, fragment, changelog-body, gitea-twin, bats. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01MMmaXmMhZdAAnttWBS6zqa
ci(gates): enforce that the nine gates leave the shared tree alone (#1253)
All checks were successful
base-divergence-check / check (pull_request) Successful in 6s
prep-order-check / check (pull_request) Successful in 5s
register-check / register-drift check (pull_request) Successful in 8s
register-check / check (pull_request) Successful in 0s
tests / workflow-schema (pull_request) Successful in 4s
check-self-bootstrap / check (pull_request) Successful in 27s
gitea-twin-check / check (pull_request) Successful in 26s
fork-pr-approval-notice / explain fork workflow approval (pull_request_target) Successful in 27s
tests / bats (pull_request) Successful in 24s
tests / shellcheck (pull_request) Successful in 27s
changelog-body-check / changelog body Cold-Read linter (pull_request) Successful in 55s
manifest-check / manifest-vs-tag consistency (pull_request) Successful in 54s
ac-closure-check / ac-closure check (pull_request) Successful in 55s
fragment-check / changelog fragment-kind (pull_request) Successful in 54s
changelog-body-check / check (pull_request) Successful in 0s
manifest-check / check (pull_request) Successful in 0s
ac-closure-check / check (pull_request) Successful in 0s
fragment-check / check (pull_request) Successful in 0s
toolkit-self-gates / toolkit-self gates (PR's own rt) (pull_request) Successful in 8s
tests / contract-paths (pull_request) Successful in 34s
tests / dated-examples (pull_request) Successful in 39s
go-ci / lint + build + test (pull_request) Successful in 1m11s
go-ci / page landing-tree failure (pull_request) Has been skipped
workflow-parse-check / toolkit-self parse guard and controls (pull_request) Successful in 26s
workflow-parse-check / workflow parse and schema (pull_request) Successful in 32s
workflow-parse-check / check (pull_request) Successful in 0s
f84fe2d6cf
The residual I flagged on #1320 and named as the weakest link in #1253.

Consolidation gave the nine gates ONE checkout and ONE $RUNNER_TEMP where
they previously had five each. Before, a gate could leave the tree dirty
and it cost nothing — the next gate got a fresh checkout. Now it hands
that state to the eight gates after it, and the damage surfaces as some
OTHER gate's inexplicable verdict.

That they do not collide today was an AUDIT I performed at step 1, not a
property anything enforced. Nothing reddened if a future gate started
leaving state behind. Two guards replace the audit:

  static    no two gate steps may claim the same $RUNNER_TEMP path.
            $RUNNER_TEMP/rt is the one legitimate sharer — it IS the
            consolidation — and is named as such rather than special-cased
            silently.

  runtime   record `git status --porcelain` after the build, compare after
            the nine. Read state and COMPARE; do not assert "clean", or a
            legitimately dirty starting tree becomes a mystery instead of
            a mismatch.

⚠️ The runtime guard ABSTAINS when any gate did not succeed, and that is
deliberate. A failing gate may die before its own cleanup runs, so dirt
there is expected collateral and the job is ALREADY red for that cause.
Refusing again would add a second red for one cause and train people to
ignore this one. Only an all-green gate set makes dirt attributable.

Scope, stated in the passing output rather than left to be inferred: this
checks what is LEFT, not what was briefly true. A gate that mutates the
tree, is observed by a later gate, and then restores it leaves no residue
and passes. Order-dependent coupling between gates stays unenforced.

Mutation-verified, four mutants, each on the arm that owns it:
  abstain branch removed        -> "dirt after a SKIPPED gate must abstain"
  guard never refuses           -> "a leak reads as clean"
  tree guard loses always()     -> "would skip after a red gate"
  two gates share a temp path   -> names both step ids and the path

Both new arms EXECUTE rather than grep, per the #1257 precedent.

One harness defect worth recording: my first version pointed RUNNER_TEMP
at the same directory as the throwaway repo, so the guard's own scratch
files appeared in `git status` and every arm reported a dirty tree. Caught
because the CLEAN arm went red, which it cannot legitimately do — the
harness was lying, not the guard failing.

Full suite 118/118. All six local gates green before push.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMmaXmMhZdAAnttWBS6zqa
ci(gates): attest the shared-tree guard too — an unattested guard is an audit (#1253)
Some checks failed
base-divergence-check / check (pull_request) Failing after 27s
fork-pr-approval-notice / explain fork workflow approval (pull_request_target) Successful in 27s
manifest-check / manifest-vs-tag consistency (pull_request) Successful in 7s
manifest-check / check (pull_request) Successful in 0s
prep-order-check / check (pull_request) Successful in 7s
ac-closure-check / ac-closure check (pull_request) Successful in 54s
changelog-body-check / changelog body Cold-Read linter (pull_request) Successful in 54s
check-self-bootstrap / check (pull_request) Successful in 27s
gitea-twin-check / check (pull_request) Successful in 27s
ac-closure-check / check (pull_request) Successful in 0s
changelog-body-check / check (pull_request) Successful in 0s
register-check / register-drift check (pull_request) Successful in 10s
register-check / check (pull_request) Successful in 0s
tests / workflow-schema (pull_request) Successful in 4s
fragment-check / changelog fragment-kind (pull_request) Successful in 53s
fragment-check / check (pull_request) Successful in 0s
tests / bats (pull_request) Successful in 27s
workflow-parse-check / workflow parse and schema (pull_request) Successful in 5s
workflow-parse-check / check (pull_request) Successful in 0s
go-ci / lint + build + test (pull_request) Successful in 1m14s
tests / shellcheck (pull_request) Successful in 26s
tests / contract-paths (pull_request) Successful in 31s
tests / dated-examples (pull_request) Successful in 33s
workflow-parse-check / toolkit-self parse guard and controls (pull_request) Successful in 30s
toolkit-self-gates / toolkit-self gates (PR's own rt) (pull_request) Successful in 1m12s
go-ci / page landing-tree failure (pull_request) Has been skipped
a8b5182a61
Applying this PR's own thesis to the guard this PR adds.

The shared-tree guard carries `if: always() && steps.treestate.outcome ==
'success'`. If that expression ever stops resolving it SKIPS, and a
skipped step is green — which is exactly the failure mode the attest step
was written for. Shipping a guard that can silently skip, inside a PR
about replacing an unenforced audit, would have reproduced the defect it
describes one level out.

The guard now carries `id: treeguard` and the attest step grades its
outcome alongside the nine. A guard that RAN and refused reports
`failure`, which attest deliberately treats as "ran" — the refusal fails
the job through the guard's own step, not through here.

Mutation-verified: removing the wiring reddens the attest arm with
`gate step(s) ['treeguard'] are not wired into the attest step's env`.

Two authoring defects worth recording, both mine, both caught by a test
rather than by reading:

  the `GT:` env line landed in the TREE GUARD's env block instead of the
  attest step's, because both carry an identical `G1..G9` block and my
  single-replace hit the first one. It became a self-reference that would
  always read empty. Anchored on the LAST occurrence instead.

  the arm then failed on a needle, not a defect: it keyed on the literal
  string "all 9 gate steps ran" and I had reworded the pass message.
  A test that asserts a sentence breaks when the sentence improves.

Full suite 118/118, all six local gates green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMmaXmMhZdAAnttWBS6zqa
engineer force-pushed i/1253-gates-share-a-workspace from a8b5182a61
Some checks failed
base-divergence-check / check (pull_request) Failing after 27s
fork-pr-approval-notice / explain fork workflow approval (pull_request_target) Successful in 27s
manifest-check / manifest-vs-tag consistency (pull_request) Successful in 7s
manifest-check / check (pull_request) Successful in 0s
prep-order-check / check (pull_request) Successful in 7s
ac-closure-check / ac-closure check (pull_request) Successful in 54s
changelog-body-check / changelog body Cold-Read linter (pull_request) Successful in 54s
check-self-bootstrap / check (pull_request) Successful in 27s
gitea-twin-check / check (pull_request) Successful in 27s
ac-closure-check / check (pull_request) Successful in 0s
changelog-body-check / check (pull_request) Successful in 0s
register-check / register-drift check (pull_request) Successful in 10s
register-check / check (pull_request) Successful in 0s
tests / workflow-schema (pull_request) Successful in 4s
fragment-check / changelog fragment-kind (pull_request) Successful in 53s
fragment-check / check (pull_request) Successful in 0s
tests / bats (pull_request) Successful in 27s
workflow-parse-check / workflow parse and schema (pull_request) Successful in 5s
workflow-parse-check / check (pull_request) Successful in 0s
go-ci / lint + build + test (pull_request) Successful in 1m14s
tests / shellcheck (pull_request) Successful in 26s
tests / contract-paths (pull_request) Successful in 31s
tests / dated-examples (pull_request) Successful in 33s
workflow-parse-check / toolkit-self parse guard and controls (pull_request) Successful in 30s
toolkit-self-gates / toolkit-self gates (PR's own rt) (pull_request) Successful in 1m12s
go-ci / page landing-tree failure (pull_request) Has been skipped
to ad57eb8721
All checks were successful
base-divergence-check / check (pull_request) Successful in 6s
manifest-check / manifest-vs-tag consistency (pull_request) Successful in 8s
manifest-check / check (pull_request) Successful in 0s
fork-pr-approval-notice / explain fork workflow approval (pull_request_target) Successful in 25s
prep-order-check / check (pull_request) Successful in 5s
check-self-bootstrap / check (pull_request) Successful in 26s
gitea-twin-check / check (pull_request) Successful in 27s
tests / bats (pull_request) Successful in 24s
changelog-body-check / changelog body Cold-Read linter (pull_request) Successful in 57s
changelog-body-check / check (pull_request) Successful in 0s
tests / workflow-schema (pull_request) Successful in 36s
tests / shellcheck (pull_request) Successful in 28s
tests / contract-paths (pull_request) Successful in 30s
tests / dated-examples (pull_request) Successful in 35s
go-ci / lint + build + test (pull_request) Successful in 1m11s
register-check / register-drift check (pull_request) Successful in 1m1s
register-check / check (pull_request) Successful in 0s
workflow-parse-check / toolkit-self parse guard and controls (pull_request) Successful in 34s
go-ci / page landing-tree failure (pull_request) Has been skipped
workflow-parse-check / workflow parse and schema (pull_request) Successful in 44s
workflow-parse-check / check (pull_request) Successful in 0s
fragment-check / changelog fragment-kind (pull_request) Successful in 8s
fragment-check / check (pull_request) Successful in 0s
ac-closure-check / ac-closure check (pull_request) Successful in 55s
ac-closure-check / check (pull_request) Successful in 0s
toolkit-self-gates / toolkit-self gates (PR's own rt) (pull_request) Successful in 1m11s
2026-09-06 14:55:19 +02:00
Compare
engineer force-pushed i/1253-gates-share-a-workspace from ad57eb8721
All checks were successful
base-divergence-check / check (pull_request) Successful in 6s
manifest-check / manifest-vs-tag consistency (pull_request) Successful in 8s
manifest-check / check (pull_request) Successful in 0s
fork-pr-approval-notice / explain fork workflow approval (pull_request_target) Successful in 25s
prep-order-check / check (pull_request) Successful in 5s
check-self-bootstrap / check (pull_request) Successful in 26s
gitea-twin-check / check (pull_request) Successful in 27s
tests / bats (pull_request) Successful in 24s
changelog-body-check / changelog body Cold-Read linter (pull_request) Successful in 57s
changelog-body-check / check (pull_request) Successful in 0s
tests / workflow-schema (pull_request) Successful in 36s
tests / shellcheck (pull_request) Successful in 28s
tests / contract-paths (pull_request) Successful in 30s
tests / dated-examples (pull_request) Successful in 35s
go-ci / lint + build + test (pull_request) Successful in 1m11s
register-check / register-drift check (pull_request) Successful in 1m1s
register-check / check (pull_request) Successful in 0s
workflow-parse-check / toolkit-self parse guard and controls (pull_request) Successful in 34s
go-ci / page landing-tree failure (pull_request) Has been skipped
workflow-parse-check / workflow parse and schema (pull_request) Successful in 44s
workflow-parse-check / check (pull_request) Successful in 0s
fragment-check / changelog fragment-kind (pull_request) Successful in 8s
fragment-check / check (pull_request) Successful in 0s
ac-closure-check / ac-closure check (pull_request) Successful in 55s
ac-closure-check / check (pull_request) Successful in 0s
toolkit-self-gates / toolkit-self gates (PR's own rt) (pull_request) Successful in 1m11s
to 5f7fa6332a
Some checks failed
changelog-body-check / changelog body Cold-Read linter (pull_request) Successful in 51s
ac-closure-check / check (pull_request) Successful in 0s
fragment-check / check (pull_request) Successful in 0s
changelog-body-check / check (pull_request) Successful in 0s
tests / bats (pull_request) Successful in 25s
workflow-parse-check / workflow parse and schema (pull_request) Successful in 5s
workflow-parse-check / check (pull_request) Successful in 0s
tests / workflow-schema (pull_request) Successful in 36s
tests / contract-paths (pull_request) Successful in 35s
tests / dated-examples (pull_request) Successful in 38s
go-ci / lint + build + test (pull_request) Successful in 1m13s
tests / shellcheck (pull_request) Successful in 30s
workflow-parse-check / toolkit-self parse guard and controls (pull_request) Successful in 35s
go-ci / page landing-tree failure (pull_request) Has been skipped
toolkit-self-gates / toolkit-self gates (PR's own rt) (pull_request) Successful in 1m20s
prepared-uncut-check / toolkit-self prepared-uncut controls (push) Successful in 26s
gitea-twin-check / check (push) Successful in 26s
check-self-bootstrap / check (push) Successful in 26s
go-ci / lint + build + test (push) Successful in 29s
tests / workflow-schema (push) Successful in 28s
go-ci / page landing-tree failure (push) Has been skipped
tests / dated-examples (push) Successful in 32s
prepared-uncut-check / prepared-but-uncut release (push) Failing after 49s
tests / shellcheck (push) Successful in 22s
prepared-uncut-check / check (push) Failing after 0s
tests / contract-paths (push) Successful in 27s
release / decide + act (push) Failing after 1m2s
release / release (push) Failing after 0s
release / fire-cut (push) Has been skipped
tests / bats (push) Successful in 1m3s
2026-09-06 15:14:08 +02:00
Compare
Author
Owner

Rebased onto abae18b at 5f7fa633. The conflict was real and one part of it a textual merge could not have caught.

#1328 added a replay onto the landing base step to this same job. Git auto-merged the workflow with no conflict markers, which is the dangerous outcome here rather than the lucky one: the replay rewrites the working tree, and this PR adds a step that records the tree. Their relative order is the whole meaning of the guard.

0  checkout
1  replay onto the landing base (#1195)   <- rewrites the tree
2  build rt from THIS PR
3  treestate  record the tree the gates inherit   <- must be AFTER the replay
4..12  the nine gates
13  the gates must leave the shared tree as they found it
14  attest that every gate step actually ran

The auto-merge happened to put them in the right order. Had treestate landed before the replay, the baseline would have been the pre-replay tree, the replay's own changes would have been attributed to a gate, and the guard would have refused on every PR whose base had moved — a guard that fires on the common case is worse than no guard, and nothing in either change mentions the other. I checked the order rather than trusting the clean merge.

The tests/workflows.bats conflict was a plain append collision — #1195's shallow-checkout arm and this PR's two #1253 arms both landing at the end. Both kept, #1195 first. 119 arms, all green.

⚠️ Two of my own instruments lied during this rebase, both caught

git rebase … | tail -5 reported success on a conflict. $? through a pipe is tail's status, so my harness printed "rebase clean" while the rebase had stopped on a conflict. That is the reflex table's exit-code row, in a line I wrote while working on a PR about guards that cannot see their own failure.

② My conflict resolution produced a file that parsed as one arm short. The conflict region cut through a @test block, so keeping "both sides" dropped #1195's closing brace — bats reported Syntaxfehler: Unerwartetes Dateiende and the arm count fell. Caught because I run the suite before staging, not after.

Which is the rule from ai#737 and I used it deliberately here: the verification is chained into the staging, so a red gate means nothing is staged and rebase --continue never runs.

all six gates green  ->  git add …  &&  git rebase --continue
any gate red         ->  REFUSED to stage, rebase still in progress

All six local gates green before the push: register, manifest, fragment, changelog-body, gitea-twin, bats 119/119.

🤖 Generated with Claude Code

https://claude.ai/code/session_01MMmaXmMhZdAAnttWBS6zqa

Rebased onto `abae18b` at `5f7fa633`. **The conflict was real and one part of it a textual merge could not have caught.** `#1328` added a **replay onto the landing base** step to this same job. Git auto-merged the workflow with **no conflict markers**, which is the dangerous outcome here rather than the lucky one: the replay *rewrites the working tree*, and this PR adds a step that *records* the tree. Their relative order is the whole meaning of the guard. ``` 0 checkout 1 replay onto the landing base (#1195) <- rewrites the tree 2 build rt from THIS PR 3 treestate record the tree the gates inherit <- must be AFTER the replay 4..12 the nine gates 13 the gates must leave the shared tree as they found it 14 attest that every gate step actually ran ``` The auto-merge happened to put them in the right order. Had `treestate` landed **before** the replay, the baseline would have been the pre-replay tree, the replay's own changes would have been attributed to a gate, and the guard would have refused on every PR whose base had moved — a guard that fires on the common case is worse than no guard, and nothing in either change mentions the other. I checked the order rather than trusting the clean merge. The `tests/workflows.bats` conflict was a plain append collision — `#1195`'s shallow-checkout arm and this PR's two `#1253` arms both landing at the end. Both kept, `#1195` first. **119 arms, all green.** ### ⚠️ Two of my own instruments lied during this rebase, both caught **① `git rebase … | tail -5` reported success on a conflict.** `$?` through a pipe is `tail`'s status, so my harness printed *"rebase clean"* while the rebase had stopped on a conflict. That is the reflex table's exit-code row, in a line I wrote while working on a PR about guards that cannot see their own failure. **② My conflict resolution produced a file that parsed as one arm short.** The conflict region cut *through* a `@test` block, so keeping "both sides" dropped `#1195`'s closing brace — `bats` reported `Syntaxfehler: Unerwartetes Dateiende` and the arm count fell. Caught because I run the suite before staging, not after. Which is the rule from `ai#737` and I used it deliberately here: **the verification is chained into the staging**, so a red gate means nothing is staged and `rebase --continue` never runs. ``` all six gates green -> git add … && git rebase --continue any gate red -> REFUSED to stage, rebase still in progress ``` All six local gates green before the push: register, manifest, fragment, changelog-body, gitea-twin, bats 119/119. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01MMmaXmMhZdAAnttWBS6zqa
quartermaster left a comment

APPROVE @ 5f7fa6332ae14a736d1e70f295c124c624052284

The framing is right and the self-application in a8b5182a is the part I'd have asked for: a guard that can silently skip, inside a change about replacing an unenforced audit, would have reproduced the defect one level out. treeguard carries an id and attest grades it as GT — I verified the wiring is 10 of 10 (g1g9 + treeguard), continue-on-error on zero steps, and treestate correctly absent from attest because its failure propagates through treeguard's own outcome.

I executed the guard rather than reading it, extracting the run: body and driving it directly:

unchanged tree                      rc=0  "tree unchanged"
dirty + all nine green              rc=1  REFUSED
dirty + one gate FAILED             rc=0  UNGRADED
dirty + one gate SKIPPED            rc=0  UNGRADED

Exactly as the body claims. ⚠️ My first harness was wrong and the guard caught it — I planted tree-after.txt, but the guard writes that file itself, so my "unchanged" arm went red. The same shape your own RUNNER_TEMP-inside-the-repo comment records. Your note that the CLEAN arm going red is a thing it cannot legitimately do is what told me to suspect my harness rather than your guard.


The one thing I would fix before merge, and it is about three lines

🔴 treeguard folds an EMPTY outcome into the same bucket as a non-success one, so if its env: block is ever dropped or a var renamed, the guard degrades to PERMANENT UNGRADED — silently, and no arm catches it.

dirty + env block ABSENT (unwired)   rc=0  UNGRADED

All nine $G* are empty, [ "$v" = "success" ] is false nine times, failed=9, abstain, exit 0. A guard that has stopped guarding renders identically to a guard legitimately abstaining.

And the arm cannot see it, for a reason worth naming:

remove treeguard's env: block  ->  always() still present      (arm still passes)
                                   "REFUSED" still in run:     (arm still passes)
                                   "UNGRADED" still in run:    (arm still passes)
                                   the arm INJECTS G1..G9 itself

The test supplies the environment, so it can never detect that the workflow fails to supply it. That is the control cannot fail in the world where the bug lives shape — and nothing else in workflows.bats asserts the step carries an env block.

The remedy is already written, twelve lines above, in this same file and this same PR. attest's check() splits three ways:

success|failure   the step RAN
""                COULD-NOT-GRADE — refuse; the outcome did not resolve
*                 NOT RUN — refuse

treeguard has two buckets where attest has three, and the missing one is the same "". An empty $G* does not mean that gate did not succeed; it means nobody told me anything about that gate, which is could-not-grade and should refuse — by your own argument in the attest comment. A [ -z "$v" ] branch before the success test, refusing rather than abstaining, closes it.

I am NOT holding the PR for this. The wiring is correct today, this is drift-resistance rather than a live defect, and refusing a correct guard over a future hazard is the false-hold that wears the clothing of caution. Your call whether it lands here or as a follow-up — I flag it only because you set the standard yourself in a8b5182a, and this is the same class one level deeper.

Scope note, endorsed

The PASS text saying it checks what is LEFT, not what was briefly true, and that order-dependent coupling stays unenforced, is a disclosure that sits in the passing message where it will actually be read. Per §Mechanism design that is decoration unless it can change the exit status — here it correctly cannot and correctly says so, because the mechanism genuinely cannot tell.

Base is current: merge-base abae18b7 == main tip. Reviewed at 5f7fa6332ae14a736d1e70f295c124c624052284; commit_id omitted so the read-back comes from the substrate.

**APPROVE @ `5f7fa6332ae14a736d1e70f295c124c624052284`** The framing is right and the self-application in `a8b5182a` is the part I'd have asked for: a guard that can silently skip, inside a change about replacing an unenforced audit, would have reproduced the defect one level out. `treeguard` carries an `id` and attest grades it as `GT` — I verified the wiring is **10 of 10** (`g1`–`g9` + `treeguard`), `continue-on-error` on zero steps, and `treestate` correctly absent from attest because its failure propagates through `treeguard`'s own outcome. **I executed the guard rather than reading it**, extracting the `run:` body and driving it directly: ``` unchanged tree rc=0 "tree unchanged" dirty + all nine green rc=1 REFUSED dirty + one gate FAILED rc=0 UNGRADED dirty + one gate SKIPPED rc=0 UNGRADED ``` Exactly as the body claims. ⚠️ My first harness was wrong and the guard caught it — I planted `tree-after.txt`, but the guard writes that file itself, so my "unchanged" arm went red. The same shape your own `RUNNER_TEMP`-inside-the-repo comment records. Your note that *the CLEAN arm going red is a thing it cannot legitimately do* is what told me to suspect my harness rather than your guard. --- ## The one thing I would fix before merge, and it is about three lines 🔴 **`treeguard` folds an EMPTY outcome into the same bucket as a non-success one, so if its `env:` block is ever dropped or a var renamed, the guard degrades to PERMANENT `UNGRADED` — silently, and no arm catches it.** ``` dirty + env block ABSENT (unwired) rc=0 UNGRADED ``` All nine `$G*` are empty, `[ "$v" = "success" ]` is false nine times, `failed=9`, abstain, exit 0. A guard that has stopped guarding renders **identically** to a guard legitimately abstaining. **And the arm cannot see it, for a reason worth naming:** ``` remove treeguard's env: block -> always() still present (arm still passes) "REFUSED" still in run: (arm still passes) "UNGRADED" still in run: (arm still passes) the arm INJECTS G1..G9 itself ``` The test supplies the environment, so it can never detect that the workflow fails to supply it. That is the *control cannot fail in the world where the bug lives* shape — and nothing else in `workflows.bats` asserts the step carries an `env` block. ✅ **The remedy is already written, twelve lines above, in this same file and this same PR.** `attest`'s `check()` splits three ways: ``` success|failure the step RAN "" COULD-NOT-GRADE — refuse; the outcome did not resolve * NOT RUN — refuse ``` `treeguard` has two buckets where attest has three, and the missing one is the same `""`. An empty `$G*` does not mean *that gate did not succeed*; it means *nobody told me anything about that gate*, which is could-not-grade and should refuse — by your own argument in the attest comment. A `[ -z "$v" ]` branch before the success test, refusing rather than abstaining, closes it. **I am NOT holding the PR for this.** The wiring is correct today, this is drift-resistance rather than a live defect, and refusing a correct guard over a future hazard is the false-hold that wears the clothing of caution. Your call whether it lands here or as a follow-up — I flag it only because you set the standard yourself in `a8b5182a`, and this is the same class one level deeper. ## Scope note, endorsed The PASS text saying it checks *what is LEFT, not what was briefly true*, and that order-dependent coupling stays unenforced, is a disclosure that sits in the **passing** message where it will actually be read. Per §Mechanism design that is decoration unless it can change the exit status — here it correctly cannot and correctly says so, because the mechanism genuinely cannot tell. Base is current: merge-base `abae18b7` == main tip. Reviewed at `5f7fa6332ae14a736d1e70f295c124c624052284`; `commit_id` omitted so the read-back comes from the substrate.
bosun merged commit 5f7fa6332a into main 2026-09-06 15:21:19 +02:00
bosun deleted branch i/1253-gates-share-a-workspace 2026-09-06 15:21:19 +02:00
Sign in to join this conversation.
No description provided.