Skip to content

ci: gate a breaking range on a new UPGRADING section - #1598

Merged
davidfarah2003 merged 30 commits into
mainfrom
fix/1578-upgrade-section-gate
Sep 14, 2026
Merged

davidfarah2003 merged 30 commits into
mainfrom
fix/1578-upgrade-section-gate

Conversation

@davidfarah2003

@davidfarah2003 davidfarah2003 commented Sep 13, 2026 •

Copy link
Copy Markdown
Contributor

Refs #1578.

Split out of #1588 so the instrument can be reviewed on its own terms. #1588 keeps docs/UPGRADING.md; this PR adds the check that keeps the page true, plus a one-page design note.

What it does

Refuses a commit range that carries a breaking change and adds no new release section to docs/UPGRADING.md in that same range.

Coverage is sections the range added, base against head. Deliberately not "did the range touch the page" and not "how many sections exist at head": the first version asked the weaker question and accepted on the page's total count, so a breaking change shipped beside a typo fix passed on sections written for earlier releases. A section must be a ## heading naming a release, because coverage is claimed by a heading and one that names no release claims everything while distinguishing nothing.

Misuse exits 2, a genuine refusal exits 1. They used to share a code, so a mistyped ref reported a missing section for a verdict never computed.

What it cannot do

Stated in the note where a reader meets it, because a check trusted past its evidence is worse than none:

  • It cannot tell whether the section is correct. It proves one was written.
  • It cannot see a break whose commit was never marked breaking. The detector reads commit subjects.
  • It knows ranges, not releases.

One correction to the framing this PR was requested under

I was asked to write that the credential-shape change behind #1578 carried no marker, so the gate would not have fired on it. That is not what the range measures, so the note says the opposite.

The change is 36d177951 feat(core)!: bind hosted runs to the caller's issued authority, located by searching the release range for the renewal refusal text operators actually hit (carries no issuance) with a must-be-absent control returning zero. It carries its !. Replaying the gate:

$ node scripts/upgrade-section-gate.mjs --range v0.48.2..v0.49.0
upgrade-section-gate v0.48.2..v0.49.0
  breaking: 36d177951 feat(core)!: bind hosted runs to the caller's issued authority (#1395)
  breaking: c9ea09136 fix(core)!: reclaim a provably orphaned endpoint governance slot (#1419)
  new docs/UPGRADING.md sections added by this range: 0
REFUSED: 2 breaking change(s) and no new docs/UPGRADING.md section in the same range
$ echo $?
1

So run at the time, this check would have blocked the release that produced the issue until someone wrote the page. That is a stronger claim than I was asked to make and it is the measured one. The unmarked case remains genuinely uncovered and no marker-keyed detector can close it, which is why the page states the credential-shape rule in prose for people rather than relying on the check.

Every line quoted above was matched back against the tool's emission, with a never-emitted line as the control.

Verification

Every figure below says which head it was measured at. Figures measured at an earlier head keep
that head's SHA and are labelled historical; they have not been re-taken here.

Measured at 12736bdbba57a05ae5fddd92b47442828d09aac8 (this PR's head
a80dde629afa8e6f413597fbb344a0ee87c3096c plus the two mutation-fixture entries below; the gate
source scripts/upgrade-section-gate.mjs is byte-identical between the two, so the cell counts
apply to both). Tree 04e1e150ebbc2f5029ff2577727b1159fc1aab96.

  • --self-test, one invocation. THE COUNT DEPENDS ON THE CHECKOUT, and all three figures are real:
    • 95 passed, 0 failed at full depth. This is the complete number.
      UPGRADE SECTION GATE SELF-TEST OK (95 passed, 0 failed)
    • 93 passed, 0 failed, 2 UNGRADED in a shallow checkout. This is not a shortfall: two cells
      need real release history, and the suite reports them UNGRADED rather than passing them, which
      is the honest disposition. Verbatim:
      UPGRADE SECTION GATE SELF-TEST OK (93 passed, 0 failed, 2 UNGRADED (shallow checkout: run git fetch --unshallow to grade them))
    • 94 is neither of those: it is the suite's own EXPECTED cell count before the final count
      sentinel, printed as every cell ran or was reported ungraded (94 before this sentinel).
      94 before the sentinel + the sentinel itself = 95.
      A previous revision of this section said 90 cells. That figure was stale and it UNDERSTATED the
      suite; it has been replaced by the three measured numbers above rather than by the largest one.
      Three replay legs are included: breaking with no new section refuses, breaking with a section
      passes, non-breaking with no section passes. The last two are what distinguish a working gate
      from one that reds on everything.
  • Mutation fixture: 29 mutants, 29 killed, each redding a named cell, run at
    12736bdbba57a05ae5fddd92b47442828d09aac8 at full depth against a baseline of 95. Verbatim
    tally line:
    All 29 mutation(s) killed. The suite discriminates.
    with baseline green (95 progress marks). Command:
    node scripts/mutation-proof.mjs --config scripts/mutations/upgrade-section-gate.json (exit 0).
    Two entries are new in this revision, added because the two CHANGESETS AGREEMENT E2E cells for
    missing closing delimiter and empty frontmatter block read well and graded nothing: the
    fixture's only parser mutant changes major-VALUE selection, which cannot move a cell whose
    subject is boundary REJECTION. M28 turns the no-boundary throw into an empty result (fail-open)
    and M29 turns the parsed-null return into a throw (fail-closed), so the pair grades both
    dispositions of the same branch. Each redded exactly its named cell at 94 marks against the 95
    baseline.
  • CHECKOUT-DEPENDENCE OF THE FIXTURE, worth knowing before anyone reruns it: in a shallow clone
    the same fixture reports 28 killed, 1 survived and exits 1. The survivor is M11, which
    replaces the --is-shallow-repository probe with a literal true; in a genuinely shallow clone
    that is a no-op, so the cell cannot move (93 marks against a 93 baseline). It is an artefact of
    the checkout, not a coverage gap. Run the fixture at full depth.
  • Registered in ci-suites.d, and confirmed to land in a shard rather than merely being present
    in the file.
  • check:docsbundle: NOT VERIFIED at this head in this clone. The run exits 1 before it reaches
    the docs comparison, because a workspace build artefact is absent
    (ERR_MODULE_NOT_FOUND: extensions/connector-core/node_modules/@cotal-ai/core/dist/index.js,
    imported from extensions/connector-core/src/tool-specs.ts). Its first leg passes
    (check:docs-voice: 41 pages passed). This is a missing local build, not observed docs drift,
    but it means the earlier "exits 0 with a clean tree" claim is UNSUPPORTED HERE and is withdrawn
    rather than restated. It needs a run in an environment with the workspace built.

HISTORICAL, measured before the latest exact-head review fix and NOT re-taken at this head:

  • CI job 103893748145 (attribution, conclusion success). The wired step printed:
    grading the merge snapshot's own range: e700ce2a2eb499616e913942ceb0f2f45d5e35e4..8447744b953c4308fb809a0986ca358123db1887
    then OK: no breaking commits DETECTED in range. The base it resolved is the branch's actual
    fork point, reached through the merge snapshot's first parent. This run predates this head; it is
    kept with its own SHAs rather than re-labelled as current.
  • The earlier "latest local exact-head" fixture proof quoted 27 KILLED ... against the fixture's own declared count of 27. That claim carried no head SHA or run identifier, and the fixture is
    now 29 entries, so it is superseded by the 29/29 measurement above rather than edited in place.

Open questions

  1. RESOLVED during review: it now runs on every PR as a wired CI step, not only as a self-test.
    The step grades the merge snapshot's own HEAD^1..HEAD range rather than the event payload's
    base, so a main-side commit cannot supply coverage for a breaking commit on the branch.
  2. Should an unmarked credential-touching change be detectable at all, or is prose plus review the honest answer? The note takes the second position and it is the part most worth arguing with.

Head note. The pushed head is 060c5e7c7b0649bd8303db01546eb4f469c6c8c7, which carries the fixture commit 12736bdbba57a05ae5fddd92b47442828d09aac8 and the design-note correction and nothing else; scripts/upgrade-section-gate.mjs is byte-identical to a80dde629afa8e6f413597fbb344a0ee87c3096c. Reproduced on a second machine at the pushed head: self-test 95 passed, 0 failed; node scripts/mutation-proof.mjs --config scripts/mutations/upgrade-section-gate.json printed "All 29 mutation(s) killed. The suite discriminates." with exit 0. CI has not yet run on the two new commits.

Head note, second push: the head is now 3ff1f17e7e0aeb6b2cd3e99489840c44c9722ae0. On top of the previous head it carries two one-line commits: the design note now says the fixture carries 29 mutants, and the gate script drops an import it never used. Reproduced at this sha on a second machine: self-test 95 passed, 0 failed; the mutation proof printed "All 29 mutation(s) killed. The suite discriminates." with exit 0; shard-stability STABLE; docs voice clean. CI has not yet run on the two new commits.

@davidfarah2003
davidfarah2003 changed the base branch from main to fix/1578-upgrading-contract September 13, 2026 21:10
davidfarah2003 added a commit that referenced this pull request Sep 13, 2026
An external report on 0.49.0 found that nothing told an operator what
changed for a deployment already running, so the reporter read a flag
that had existed for many minors as new and stopped on it.

docs/UPGRADING.md is that page: what keeps working across the release,
what an operator must do and in which order, the boot wait bound to the
new manager rather than to whatever the append-only log already holds,
and the reasoned-versus-measured labelling on the two topologies. Every
source claim on the page was checked against v0.48.2 and v0.49.0, every
fenced block was executed as a program from a dirty log state, and the
regenerated docs bundle matches the committed blob.

A repository check that refuses a breaking range with no new section on
this page is proposed separately in #1598.

Refs #1578
@davidfarah2003
davidfarah2003 changed the base branch from fix/1578-upgrading-contract to main September 13, 2026 22:01
@davidfarah2003
davidfarah2003 force-pushed the fix/1578-upgrade-section-gate branch 3 times, most recently from b92db62 to 6649492 Compare September 14, 2026 06:17
Comment thread scripts/upgrade-section-gate.mjs Fixed
Comment thread scripts/upgrade-section-gate.mjs Fixed
davidfarah2003 and others added 21 commits September 14, 2026 21:18
Split out of the documentation PR so the instrument can be argued about on its
own terms. Adds scripts/upgrade-section-gate.mjs, its mutation fixture, the
ci-suites.d fragment that registers it, and the two package.json scripts.

docs/design/upgrade-section-gate.md states what the gate proves (a section for
the release was written), what it cannot (whether that section is correct, and
any break whose commit was never marked breaking), and why a marker-keyed
detector is still worth running.

One claim in the note is measured and corrects the premise I was given. The
change behind #1578 is 36d1779 feat(core)!, found by searching the release
range for the renewal refusal text with a must-be-absent control, and it DOES
carry its breaking marker. Replaying the gate over v0.48.2..v0.49.0 exits 1 and
names both breaking commits, so run at the time this check would have blocked
that release until the page existed. The note says so rather than claiming the
weaker 'it would not have fired', which is not what the range measures.

The transcript in the note is captured output: every quoted line was matched
back against the tool's emission, with a never-emitted line as the control.

Refs #1578
Three defects found by review, each measured rather than reasoned about.

A shallow checkout made the gate report the opposite of the truth. The
tags still resolve at depth 1, so `git log` succeeds over a cut range:
v0.48.2..v0.49.0 returned 1 commit instead of 173 and zero breaking
changes instead of two, and the gate printed OK and exited 0. That is a
green meaning "I could not see" on the one check whose green is supposed
to mean somebody documented the break. It now exits 2 with the misuse
wording it already had for an unresolvable ref, so the truncated case can
never again be reported as a clean one. Three new cells grade it against
a real depth-1 clone, paired with an accept control proving the guard
discriminates rather than refusing everything, and a tenth mutation leg
kills the guard and reds the cell that names it.

A pass now reads "no breaking commits DETECTED", not "no breaking
commits". The gate reads markers and cannot see an unmarked break, which
the design note states plainly, so the old wording had the script
contradicting its own note on the line an operator actually reads.

The page said the check was proposed and promised to name it once it
landed. It now names the command, documents the three accepted markers,
and warns that a marker must survive the squash to be inside the graded
range. Each documented string was executed against the real predicates
with refuse controls, and the documented invocation was run as written.

Refs #1578

Follow-up in the same commit, found by review and by mutation:

The suite red under a shallow checkout for a reason that was not a
defect. Two exit-code cells shell out to the real CLI over release tags,
and the new guard correctly answers 2 there, so asserting 1 or 0 failed
while the tool behaved exactly as designed. They now report UNGRADED,
which is neither a pass nor a failure, and the summary line names the
count so a green run can never quietly mean most of it did not run.

Adding a skip introduced a worse hazard than it removed, and the
mutation sweep caught it rather than a reader: forcing the truncation
probe true on a healthy clone made the suite skip two real cells and
still exit 0. Every other shallow cell reasons about temporary
repositories it builds itself, so nothing looked at the repository the
suite was actually in. A skip mechanism with no guard is a mute button.
One cell now re-probes and asserts the skip decision matches this
repository, and the mutant that forces the probe reds it by name.
The gate existed and CI never aimed it at a pull request. The suite
registry ran the self-test alone, which grades two fixed historical
ranges, so a breaking change landing tomorrow with no upgrade section
was examined by nothing. A gate that is never aimed at the change is
worse than absent, because a green check tells a reviewer somebody
looked.

This step grades base..HEAD on every pull request, using the same base
derivation as the existing shard-stability step. Exit 2 fails the job
rather than reading as a pass, and a missing verdict line fails it too,
because empty output with a zero status would otherwise look green.
Measured: the current range exits 0, and the historical range carrying
two marked breaking changes with no new section exits 1.

A second hunk giving the smoke job full history was written, measured,
and deliberately dropped. It would have let two cells grade real release
tags instead of reporting them ungraded, which is a small gain, and it
cost an abort in the shard-stability detector: that detector pins the
smoke job's first step to a byte-exact toolchain prelude, and adding a
`with:` block to its checkout makes the topology unreadable. Weakening a
pinned supply-chain contract to buy two cells is the wrong trade, and
the suite already exits 0 under a shallow checkout by reporting them.

Refs #1578
The page edit that named the gate used a filler word the project's own
voice check rejects, and editing a page leaves the generated docs bundle
stale until it is rebuilt. Both were caught by CI rather than by me: the
unit job's "Docs bundle in sync" step runs check:docs-voice first and
then diffs a fresh generation, and it failed on the first of those.

The lesson is the one this PR is about. I validated the page by reading
it and validated the workflow with a generic YAML parser, when the
repository ships a checker for each. Running check:docs-voice takes
under a second and names the file, the line and the word.

Verified here: check:docs-voice exits 0 across 41 pages and exits 1 with
the word restored, so the checker is proven able to fail. gen:docsbundle
exits 0 and the regenerated bundle carries the new page text.

Refs #1578
The step took its base from `github.event.pull_request.base.sha` and its head
from the checked-out merge commit. Those are two different snapshots. The
payload records main as it stood when the event was created; the merge commit's
first parent is main as it stood when GitHub computed the merge. They drift
apart the moment main moves, silently.

Measured on this pull request: the event base was 0e2d464
13418c6c0801 while the merge's first parent was already 04f1b61
2a5582d49ac736ef48, so the step graded 26 changed paths over six commits where
the contribution carries eight paths over five.

That inverts the verdict rather than merely widening it. Reproduced end to end
with the shipped script: an unrelated main-side commit adding any release
section is swallowed into the range and reads as coverage, so a `feat(core)!`
that documented nothing exits 0 and prints "covered by 1 new section(s)". Over
HEAD^1..HEAD the identical commit exits 1 and REFUSES. A gate that someone
else's unrelated commit can turn green is the reporter's own defect one level up,
and this one shipped inside the gate written to catch that class.

Both ends now come off the checked-out object, which cannot drift. A
pull_request checkout that is not a two-parent merge snapshot is a misuse exit
rather than a guess, because HEAD^1 on a single-parent checkout is the branch's
previous commit and would read as a tiny clean range.

Verified by running the step body extracted from the parsed workflow, not a
paraphrase of it: the false-green scenario REFUSED at exit 1; a breaking change
carrying its own section exited 0; a non-breaking change exited 0; a push-event
break with no section exited 1; a single-parent pull_request checkout exited 2;
a shallow checkout exited 2. Self-test 40 passed 0 failed on full history.

Docs corrected in the same commit, since the prose contradicted the diff. The
operator page said the PR-range step was "landing separately" while this
workflow makes it live, and the design note said 36 cells and 9 mutants against
an actual 39 and 11. Both now state plainly that `ci-ok` is not in the branch
protection rule set, where `attribution` is the only required context today, so
a red gate reports without blocking.

Refs #1578
Two limitations the panel surfaced, written where a reader meets them rather
than left implicit.

The gate reports through `ci-ok`, and the only required context on main is
`attribution`, so a red gate is advisory today. That is a deliberate position
rather than an oversight: the org sits on a 20-concurrent-job cap and the board
carries a known wrong-red rate, so requiring `ci-ok` would block every merge
behind a flaky shard. The enforcement that exists is the operator gate, a
maintainer reading a terminal-green rollup before merging. Whether to require
`ci-ok` is a repository governance decision and is filed separately, so open
question 2 is no longer carried as an open question here.

A depth-1 checkout is a degrade, not a false pass, and the note now says so with
the mechanism. The smoke suite runs `--self-test`, which reports the two cells
needing real release history as UNGRADED and exits 0, printing the count and the
reason. Measured in a real depth-1 clone: 38 passed, 0 failed, 2 UNGRADED. The
grading path is the `unit` job at `fetch-depth: 0`, and pointing the gate at a
range inside a shallow clone is a misuse exit rather than a pass, verified in the
same clone at exit 2 for both `--range` and `--base`.

Gates run before this push, each named because a claim without one is a
prediction: check:docs-voice 41 pages passed, gen:docsbundle regenerated,
check:shard-stability STABLE=0 over 0e2d464..7b6d7a6 with 0 of 436 frozen
suites changing index and 0 of 585 changing shard.

Refs #1578
…aller

A reviewer found the guard was one layer too high. The workflow step checks
that a pull_request checkout has two parents, but the script did not, so
`--range HEAD^1..HEAD` on a single-parent branch graded the last commit alone.
Reproduced: a branch carrying `feat(core)!` with a harmless commit on top
returns "OK: no breaking commits DETECTED in range" and exits 0. The breaking
change is one commit further back and invisible.

The workflow already refused that shape, so CI was never exposed. The exposure
is everyone else: a local hook, a future workflow, or a person running the
script by hand inherits none of the caller's care. A tool that only refuses
when its caller remembers to check is correct by convention rather than by
construction.

`--merge-snapshot` moves the decision into the tool. It derives HEAD^1..HEAD
itself, refuses a checkout whose HEAD does not have exactly two parents, and
refuses being combined with an explicit range, because two sources for one
range is the defect this whole flag replaces. The workflow keeps its own
parent check as a second line of defence rather than the only one.

Measured, exit codes captured directly rather than through a pipe:

  script alone, --range HEAD^1..HEAD on the evasion   rc 0   the false green
  script, --merge-snapshot on the same checkout       rc 2   refused
  --merge-snapshot combined with --range              rc 2   refused
  real range still graded                             rc 0   accept control

Six legs on the step body extracted from the parsed workflow: merge snapshot
with an undocumented break REFUSED 1, merge snapshot with its own section 0,
single-parent checkout 2, the evasion 2, push-event break 1, push-event
ordinary 0. Two accept legs and four refuse legs, on both event types.

Self-test 42 passed 0 failed with two new cells and the sentinel moved 39 to
41. Mutation fixture grows to 13: one mutant disarms the parent check and one
disarms the combination refusal, each naming its own cell. All 13 find strings
verified to occur exactly once, with the applicability test calibrated on a
known-absent and a known-present string first.

Refs #1578
The mutation proof found the cell for the combination refusal green with the
refusal deleted: the parent check fires first and also exits 2, so an
exit-code reader cannot tell the two refusals apart. The cell was passing for
a reason unrelated to the line it names, which is the wrong-green shape this
gate exists to catch, one level up in its own suite.

Both merge-snapshot cells now assert the refusal's own sentence beside its
exit code.

Refs #1578
39 cells and 11 mutants became 42 and 13 when the merge-snapshot guard landed,
and the note still carried the old pair. Both numbers are read off runs rather
than counted by hand: full history prints 42 passed 0 failed, a real depth-1
clone prints 40 passed 0 failed 2 UNGRADED, and the fixture reports 13.

The script's own usage block now documents --merge-snapshot, including why it
refuses a single-parent checkout.

Refs #1578
… cannot read

Two defects, both measured on a real two-parent checkout before anything changed.

THE SUITE WAS READING THE WEATHER. Two cells ran `--merge-snapshot` against
whatever repository the suite happened to be in, so the result was decided by
that checkout's parent count rather than by the tool. On a branch head the
suite read 42 passed; on a pull request's own merge ref it read 41 passed 1
failed, because the refusal a cell asserted cannot happen on a merge snapshot,
where the flag correctly succeeds. CI checks that merge ref out at fetch-depth
0, so the suite went red on the one checkout that matters, and the mutation
proof, which refuses to grade against a red baseline, left all 13 mutants
ungraded. The cells now build the repositories they name, and the parent probe
is shown to discriminate before any of them are believed.

A GATE THAT GREENS ON A TYPO. The parser read an option's value by looking one
slot to the right and never asked whether the slot held one, nor whether every
argument had been understood. Measured: `--merge-snapshot --range` with no
value, `--base` with no value, and `--merge-snapshot --typo` all graded and
exited 0. A value-less `--range` read undefined, which is falsy, so the
combination refusal never fired. It survived because the shapes anyone tested
were the shapes that worked: the well-formed combination refuses correctly.

Three mutants added for the new guarantees, each proven to apply exactly once.

Refs #1578
Mutation found the gap I left in my own fix. Substituting a built repository
for `process.cwd()` kept every cell GREEN, because this lane's checkout
happens to have one parent and so happens to satisfy the refusal the cell
asserts. The cell could not tell a repository it constructed from an ambient
one that merely fit, which is the same defect one level up: a cell whose result
is decided by the weather passes for exactly as long as the weather holds.

The helper now refuses any directory outside the block's own temp root, and it
refuses by returning a failing result rather than by throwing, so the cell that
reached for the wrong repository reds on its own line instead of handing the
reader a stack trace where a verdict belongs. A sentinel cell grades the guard
itself, because an unchecked guard is the mute button this suite already caught
once in its skip mechanism.

Refs #1578
I pushed the sentinel to a number I predicted instead of the one the suite
reported, and committed a red suite for one minute. The failing line said
`{"pass":49}` the whole time. Read the row, not the count.

Refs #1578
…pe defect

Counts read off runs rather than recomputed: 50 cells, 48 plus 2 UNGRADED in a
shallow checkout, 16 mutants. The design note now carries the defect that took
the suite red in CI and why a cell must build the condition it names.

Refs #1578
…l blocks the merge

The gate refused correctly and could not stop anything. `attribution` is the only
required status context on main, measured directly from the branch ruleset, so a red
in `unit` reports through the `ci-ok` rollup and the merge proceeds regardless. A
refusal nobody has to answer is indistinguishable from a pass to the person clicking
merge, which is the failure this gate exists to remove, one level up.

The step moves into the `attribution` job. The range logic and the two-parent guard
are unchanged.

Hosting it there costs nothing, which is what makes this preferable to the alternative
of making `ci-ok` required. That alternative would put every merge behind a flaky
shard, on an org at a 20-concurrent-job cap with a known wrong-red rate. The script
imports only node builtins, exactly like the two checks already in that job, and it
was measured running to a correct verdict in a clone with no node_modules at all, so
the job still installs no dependencies. Its existing checkout is already the right
shape: fetch-depth 0 with the default pull request ref, which is the two-parent merge
snapshot the gate requires and verifies for itself.

THE PUSH BRANCH IS REMOVED RATHER THAN CARRIED. The step grades pull request merge
snapshots only, because this workflow triggers on pull_request alone. The old
HEAD~1..HEAD branch was inherited from a workflow that also ran on push, and in this
job it is unreachable. Unreachable code that claims to grade something is worse than
absent: it advertises coverage the triggers cannot exercise and leaves a second range
path free to drift out of step with the one that runs. The reason it is not wanted is
the one this workflow already gives for the attribution check, that a push to main is
already merged and so a red there is a report rather than a gate. The event is now
asserted rather than assumed, so adding a trigger later refuses to grade instead of
silently taking a path nobody re-checked.

Measured end to end by running the step body itself, in a dependency-free clone
checked out at the real merge ref. On the snapshot as it stands, exit 0 with its range
printed. With a planted `feat(core)!` carrying no section, REFUSED at exit 1. With the
same commit carrying a section, exit 0 naming the section. Event legs: pull_request
grades, push and schedule each refuse at exit 2 by name. On a single-parent checkout,
misuse exit 2 rather than a silently shrunken range. Both directions and every refusal
path, so the gate is proven able to fail and able to pass.

The design note said the gate was advisory and that enforcement was a repository
governance question filed separately. That framing assumed the step had to run where
it was first put, and it was wrong. Those passages are rewritten, and the
pull-request-only scope is recorded beside them.

Refs #1578
Moving the gate into the required job made three shipped documents false, and
the operator-facing page was the worst of them. docs/UPGRADING.md told a reader
the check runs in the `unit` job, that it is not in the branch protection rule
set, and that "a red gate reports the problem without blocking the merge". Every
operative clause was wrong the moment the step moved. An operator reading that
would not chase a red, because the page told them a red is advisory.

The generated bundle carried the false enforcement sentence into the shipped
product, where connector-core serves it to anyone asking about upgrading. That
is the half that reaches users rather than reviewers, and it is regenerated here
so the correction propagates.

The design note is corrected in the two places that were stale, and deliberately
NOT in a third. The paragraph describing the move states that an earlier revision
ran in `unit` and was advisory; that is accurate history and it is the passage
explaining why the placement matters, so a blanket sweep would have deleted the
reasoning along with the defect.

No existing check could have caught this. `check:docsbundle` regenerates and
diffs, which proves the bundle matches its source and cannot prove the source is
true: it propagates a faithful copy of a false claim at exit 0. This change's own
gate reads whether a section exists, not whether prose agrees with the workflow.
So a change whose subject is documentation going stale against a diff shipped
exactly that, with a green check beside it.

Refs #1578
The prose fix in the previous commit was a repair, not a defence. This change
makes the class detectable, because nothing in the repository could see it.

The class, measured on this pull request three times: the body claimed 36 cells
and 9 mutants against 50 and 16; an open question said the gate "currently
self-tests" after it had been wired into CI; and docs/UPGRADING.md told operators
the check runs in the `unit` job and that a red "reports the problem without
blocking the merge" after the step had moved into the required job and a red had
started blocking. The last was generated into the shipped docs bundle, so the
false promise reached users of the product.

check:docsbundle regenerates and diffs. That proves the bundle MATCHES its source
and cannot prove the source is TRUE: it propagates a faithful copy of a false
claim at exit 0. This gate read whether a section EXISTS, not whether prose agrees
with a workflow. So the tool written to stop documentation going stale against a
change had no way to notice its own documentation going stale against its own
change.

The check is narrow on purpose. It does not grade prose. It asserts one fact that
is mechanically derivable from the workflow files and is stated in the docs: which
job runs the gate. The job is read from the YAML rather than hard-coded, so moving
the step again reddens every document that still names the old job.

THE FIRST VERSION OF THIS WAS DECORATIVE AND THE MUTATION PROOF SAID SO. Three
mutants disabling the checks all SURVIVED, because the live documents were already
correct: with nothing stale to find, deleting the detector changes nothing
observable. That is the same defect the cells exist to catch, one level down. The
detector is now a pure exported function driven by documents the suite builds
itself, including a bundle-shaped escaped string, so the cells fail when the
detector is broken rather than when the repository happens to be dirty. The
bundle needs its own fixture because it stores each page as one escaped JSON
string, and a reader written for markdown can pass on the source while missing the
generated copy.

Six fixture-driven cells and one live-file leg per artifact, with both directions
asserted: a stale claim is named, a corrected page is silent, the host job is
honoured rather than assumed, prose merely mentioning a job name is not a claim,
and every stale claim in a page is reported rather than the first. Three mutations
pinned, all killed by name. Full fixture: 19 mutations, 19 killed.

Refs #1578
The check landed without being described in the note that exists to describe
this gate, which is the same omission class one layer out: a document that no
longer covers what the change does.

Three paragraphs, and the middle one is the point. The check asserts ONE fact
that is mechanically derivable from the workflow, which job runs the gate, and a
future false claim that avoids naming a job passes every leg of it. Prose about
what the gate proves, what it cannot see, or how an operator should respond to a
red is graded by nothing and cannot be, because those are claims about meaning
rather than about a value present in a file. Stating that here means the next
reader meets the ceiling beside the check rather than discovering it by trusting
a green.

Also records why the cells are driven by documents the suite builds itself. The
first version read only the real files and was decoration: with the live
documents already correct, deleting the detector changed nothing observable and
three mutations disabling it all SURVIVED. A detector driven only by real files
also cannot be shown to track the workflow, because moving the workflow under it
means moving the files too.

The history paragraph describing the earlier `unit` placement is deliberately
left intact. Measured: the detector reads a past-tense account of the old
placement as silent and a live claim as stale, so recording the history does not
trip the check that exists to catch stale placement claims.

Refs #1578
…a fixed pair

The refuse leg could only notice a document claiming `unit` or `ci-ok`, the two
jobs that had actually gone stale. Review found the evasion by execution rather
than by reading: a page naming the CORRECT host AND ALSO a false third job passed
everything. The positive check was satisfied because the right job appeared, and
the refuse leg never looked at the third because it was not in the hard-coded
pair. That is a page simultaneously correct and false, shipping green, and it is
the precise shape this leg exists to catch.

Reproduced before changing anything: planting "and the grading path is the `live`
job" beside the correct sentence left the self-test at 64 passed, 0 failed, exit
0. After the fix the same plant reddens and names {"stale":["live"]}.

The job names are now read from the workflow files, at the same site that already
locates the host job, so any job the repository defines is a candidate and the
leg tracks the workflows the way the host lookup does. A literal list can only
ever notice yesterday's mistake.

The candidate list is required rather than defaulted. An absent or empty list
would report every page clean, which is the silent-pass shape this tool refuses,
so it throws instead. That guard is what the pinned mutation kills: restoring a
silent fallback reddens the refuse-control cell by name.

Two cells added for the evasion itself and one for the guard. The ceiling
paragraph in the design note is corrected as well, since it implied the leg
covered any wrong job when it covered two; it now says the recognised set is read
from the workflows and records how the evasion was found.

Refs #1578
@davidfarah2003
davidfarah2003 force-pushed the fix/1578-upgrade-section-gate branch from 9e21173 to a80dde6 Compare September 14, 2026 19:27
…greement cells

The two CHANGESETS AGREEMENT E2E cells for 'missing closing delimiter' and
'empty frontmatter block' read well and graded nothing: the fixture's only
parser mutant changes major-value selection, which cannot move a cell whose
subject is boundary rejection.

M28 turns the no-boundary throw into an empty result (fail-open: malformed
input the installed parser rejects would pass the gate as exit 0).
M29 turns the parsed-null return into a throw (fail-closed: an empty mapping
the parser accepts would be refused as misuse exit 2).

Fixture 27 -> 29.
…n the claims to a head

The design note said 90 cells, 88+2 UNGRADED shallow, 90 full. All three were
stale and all three UNDERSTATED the suite. Measured at this head: 95 cells,
93 passed + 2 UNGRADED shallow, 95 passed full depth.

BODY-v2.md carries the rewritten Verification section: it separates the three
self-test figures (95 full / 93+2 shallow / 94 pre-sentinel expected) instead of
quoting the largest, binds the 29/29 mutation claim to a head SHA and tree hash,
records that the fixture reports 28/1 in a shallow clone because M11 is a no-op
there, and withdraws the check:docsbundle exit-0 claim as unverified rather than
restating it. Historical CI figures keep their own SHAs and are labelled as not
re-taken.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants