Skip to content

ci: every negative control runs on pull requests, enumerated from the Makefile (#563) - #659

Merged
rowan-claude merged 16 commits into
mainfrom
fix-563-controls-on-pr-ci
Sep 7, 2026
Merged

ci: every negative control runs on pull requests, enumerated from the Makefile (#563)#659
rowan-claude merged 16 commits into
mainfrom
fix-563-controls-on-pr-ci

Conversation

@rowan-claude

@rowan-claude rowan-claude commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

Twice on 2026-09-05 main's certification went red on a merge whose pull-request run was 20 of 20 green, and one of the two was a stale sabotage pattern: #562 rewrote the line the maps fit control's sed aimed at, the sabotage patched nothing, and the control refused where only make test could see it. Every negative control in this tree already refuses that way. What was missing is running them where a pull request is judged.

The rule this leg is held to

Glenn, 2026-09-07, verbatim:

Remember that if we have any CI that runs per-commit it needs to finish 1 minute, 2 minutes max. Anything longer than that needs to be nightly or manually triggered as needed.

It is a rule about a job, and a matrix row is a job, so every row of this leg is cut to fit two minutes rather than the leg as a whole being cut to fit. A control that does not fit the rule on its own is not made to fit by grouping, so it runs nightly instead.

The leg

Two jobs in ci.yml. negative-controls-matrix runs the enumeration gate and prints the plan as a matrix. negative-controls fans out over it, builds bin/schema and the generated tree once, and runs its group's controls in one make -k invocation, so a control that refuses does not hide the ones behind it.

Two more in certify.yml, the same pair over the nightly tier, on the schedule that file already carries.

Red comes from two places: make's exit status, and a scan of the log for the controls' own refusal lines (NEGATIVE CONTROL FAILED: and NEGATIVE CONTROL:, which is where patched nothing and did not apply live). The scan puts the sentence a reader needs into the run's annotations rather than leaving it a thousand compiler lines deep.

The enumeration rule

tools/negativecontrols reads the Makefile and every file the Makefile includes, collects each explicit target whose name carries negative-control, and holds that set against make/negative-controls.json, which is the plan the matrix comes from. go test ./tools/negativecontrols/ fails on a difference in either direction, on an exclusion with an empty reason, and now on a group that names neither tier, so a control cannot leave the pull request without landing on the nightly. A separate test reads both ci.yml and certify.yml and requires each to reach its matrix and its targets through the tool, so a job that typed its own list would not pass.

The reader follows the tree's own include lines rather than a glob, and reads the rule heads a makefile actually writes: several targets on one head, a head continued over a backslash, .PHONY lines, and define blocks and := assignments that only look like rules. 196 controls, up from 159 when this branch opened, all of them enumerated rather than typed.

$ go run ./tools/negativecontrols check
every one of the 196 negative controls is in a group or in an explained exclusion, and every group runs on the pull request or nightly

Measured, per job, on this branch's own run

Run 34116264000, pull_request, head 037becee. Every one of the 38 negative-control jobs is under 120 s, wall time from startedAt to completedAt:

group wall
wire-fuzz 112 s
wire-fuzz-wide-text 102 s
wire-fuzz-arm-width 99 s
wire-fuzz-message-oracle 95 s
wire-fuzz-index 94 s
base-lists 92 s
wire-fuzz-arm-terminator 91 s
base-cook 90 s
wire-fuzz-message-text 89 s
base-maps-order 88 s
wire-fuzz-length 87 s
cs-block-pitch 86 s
base-conformance 85 s
wire-fuzz-retain-class 84 s
block-fuzz-maximum 83 s
wire-fuzz-message-blob 82 s
block-fuzz 81 s
wire-fuzz-blob-node 80 s
base-maps 76 s
base-keyed 76 s
elixir 72 s
base-maps-keys 69 s
base-shared-node 68 s
cs-block-layout 66 s
base-conformance-absence 66 s
base-maps-depth 65 s
cs 64 s
base-arms 62 s
java 58 s
dart 57 s
cs-cook 56 s
base-packet 55 s
base-keyed-shift 55 s
base 53 s
js 52 s
rust 49 s
message-form 42 s
rust-alloc 39 s
negative-controls-matrix 15 s

Every other job in the file on the same run: msvc 87 s, windows 57 s, go-test 56 s, conformance 27 s to 52 s per leg, lint 44 s, vuln 37 s, generated 24 s, shape-gate 20 s, conformance-matrix 20 s, cpp-lock 11 s.

One job in the file is over the rule and it is not this leg's: big-endian at 263 s. It is 1:49 in ci.yml's own header, measured when #368 wrote it, and it is 4:15 to 4:26 on main today (runs 34111624059, 34111228369, 34110320928) with nobody having touched it. The header now says so. It wants the same treatment this leg got and has not had it, and that is a change to #368's gate rather than to this one.

How the groups were cut

Three passes, each one measured.

  1. By toolchain, which is where this branch started: base, the six per-port groups, block-fuzz, conformance, and wire-fuzz and message-form split out of base on cost. On run 34102397965 that was base 8:57, wire-fuzz 7:13, cs 2:46, message-form 2:36, conformance 2:31, block-fuzz 1:59. Five of eleven rows over the rule.
  2. By family and by cost inside a toolchain, to 34 groups. On run 34115675206 every group was inside the rule except wire-fuzz-retain, and eight sat between 100 s and 111 s.
  3. Again, on the eight, to 38. big-endian going from 1:49 to 4:19 on a job nobody changed is the argument against leaving a job inside the rule by ten seconds.

What the measurement decided, rather than the family names:

  • The map gate is 17 controls at about 4 s each on the author's machine and about 2x that on the runner, so it is four groups.
  • The tolerant-wire family is one control per job: each rebuilds the compiler under a source overlay and then fuzzes the sabotaged wire, 55 s to 80 s each, so no two of them fit together. wire-fuzz is the tightest job in the leg at 112 s and its floor is the fuzz driver's own build, which is 64 s of it.
  • The C# layout check is the instrument for three block controls at 39 s, 31 s and 29 s, so cs is four groups.
  • conformance is gone as a group. Its one control had the bare conformance target as its prerequisite, which builds and runs all nine legs and needed all seven SDKs on one runner. The prerequisite is now conformance-rust, the rust leg and the derived manifest the control actually reads, and the control rides one SDK in rust-alloc at 39 s. The soak and the audit keep conformance deliberately: a number measured over a corpus whose matrix is red is a number about a defect, and this control is not a number.

The nightly tier

One control does not fit the rule at any group size, so it runs nightly rather than being grouped into fitting:

  • tables-message-form-negative-control, 49 sabotage rows one submake each, 124 s. Its four blades stay on the pull request in message-form.

tables-wire-fuzz-retain-negative-control was here too until the cold read; it is an umbrella with no recipe, and it is in the exclusion list below. The group stays a group at one target: it is where the next control that outgrows the rule lands.

A group names its tier in a when field, matrix renders one tier, and the enumerator refuses a group that names neither. certify.yml runs the nightly tier on the schedule it already carries.

Exclusions

Six, each with a reason the test requires to be non-empty:

  • packet-arm-defaults-negative-controls, an alias that re-invokes make once per language for nine targets this leg already runs in their own groups.
  • tables-message-form-one-negative-control, a parameterized worker that reads SABOTAGE from the command line. Invoked bare it has nothing to break.
  • tables-maps-negative-controls, an umbrella over the seventeen map-gate leaves, which four groups now run between them. An umbrella in any one of them would re-run the other three groups' controls.
  • tables-wire-fuzz-negative-control, an umbrella over the twelve tolerant-wire leaves. In any one job it would re-run all twelve and take that job to six minutes.
  • tables-wire-fuzz-retain-negative-control, an umbrella over the retain oracle and retain class leaves, which wire-fuzz-message-oracle and wire-fuzz-retain-class run between them.
  • tables-big-endian-negative-control, the 263 s body of the big-endian job, which runs it on every pull request already. ci: the big-endian job runs 263 s on main, over the per-commit rule #684 owns moving that job; a second s390x cross-compile on this leg proves nothing the first does not.

Found on the way

  • conformance-negative-control-absent did not name build/conformance-go as a prerequisite, though its second half execs it. Inside make test the Go leg is already built, which is why the omission stayed invisible. Fixed here.
  • The first stale control this leg found, and the lint on the reader, are in the branch already.
  • The 37 controls main gained while this branch was measuring are in the plan, toolchain-negative-control from make test refuses a missing pinned toolchain by name, and names a skip that is asked for (#599) #660 among them. Two of them landed mid-run and the leg went red naming both rather than running 194 of 196, which is the gate doing its job.

From the cold read

Eight findings, each acted on.

1. The nightly group's why described an umbrella as a pass. Makefile:4428 gives tables-wire-fuzz-retain-negative-control no recipe: it names tables-wire-fuzz-retain-oracle-negative-control and tables-wire-fuzz-retain-class-negative-control, both already in groups. The "140 s of fuzzing the whole corpus" was the two leaves' cost, counted a second time. It moves to excluded with the sentence the other umbrellas carry, and the claim is corrected where it is repeated, in ci.yml's leg comment and in docs/CONTRIBUTING.md. The nightly group holds one target and stays a group.

2. Three negative controls the naming convention hid. The marker tools/negativecontrols reads is negative-control, and three controls the Makefile itself introduces as "Its NEGATIVE CONTROL" spelled themselves -negative: tables-big-endian-negative, tables-hostile-negative, tables-pack-negative. The enumerator could not see them, so "every negative control runs on every pull request" passed over three, and two of them ran only inside make test. All three are renamed into the convention, with every reference moved (Makefile, docs/PORTING.md, ci.yml, compiler/porting_test.go), and placed:

target where it went why
tables-big-endian-negative-control excluded the body of the big-endian job, which runs it on every pull request today; #684 owns moving that job, and the enumerator now sees it without the gate cross-compiling s390x twice
tables-hostile-negative-control new base-hostile group 14 s on the author's machine
tables-pack-negative-control new base-pack group 7 s on the author's machine

They started as one base-pack group and are two rows now. On run 34120798793 the pair was 81 s of control time in a 113 s job, the tightest job in the leg, which is the band this branch already cut once. They share less than the name suggests: tables-pack's floor is a go build -overlay and a byte comparison, and tables-hostile's floor is the SANITIZED build of the hostile-value driver, which nothing else in the leg builds and which was most of the 81 s. So base-pack is the pack control at 7 s and base-hostile is the hostile-value control at 14 s, off one warm build. The count is 203 controls now, in 40 pull-request groups and one nightly group.

3. The leg-runs-the-manifest test greped three substrings. A leg that replaced its matrix expression with a hand-typed include list and left the old expression in a comment satisfied all three. gopkg.in/yaml.v3 is not a dependency and this module has none, so the package carries a reader for the block-YAML subset these workflows are written in. The new test asserts, for both tiers, that strategy.matrix IS ${{ fromJSON(needs.<matrix-job>.outputs.matrix) }}, that the leg needs: that job, and that the matrix job's output comes from the step running this tool.

Proven red first. With ci.yml's matrix expression replaced by a hand-typed three-row include list, TestTheLegRunsTheManifestAndNotATypedList still passed:

=== RUN   TestTheLegRunsTheManifestAndNotATypedList
--- PASS: TestTheLegRunsTheManifestAndNotATypedList (0.00s)

and the new test said:

=== RUN   TestEachLegExpandsTheToolsMatrix
    negativecontrols_test.go:291: .github/workflows/ci.yml: the negative-controls job expands
    map[string]interface {}{"include":[]interface {}{map[string]interface {}{"name":"base"},
    map[string]interface {}{"name":"base-packet"}, map[string]interface {}{"name":"base-conformance"}}}
    as its matrix, want "${{ fromJSON(needs.negative-controls-matrix.outputs.matrix) }}": a matrix
    written any other way is a target list this package did not enumerate
--- FAIL: TestEachLegExpandsTheToolsMatrix (0.00s)

The change was reverted; ci.yml is byte-identical to what it was before the experiment.

4. A marked head carrying $ or % was dropped in silence. makefile.go skipped it and the test pinned the skip. A dropped head is a control in no group, in no exclusion and in no job, with every test in the package green, which is the one outcome the package exists to prevent. It is refused by name now, and the test is flipped: four spellings (a variable head, a pattern rule, a .PHONY substitution reference, and a variable head reached over a backslash continuation) each have to refuse and name the head.

5. Five toolchain versions were written twice with nothing joining them. node 20, dart 3.13.2, java 21, otp 29.0.5 and elixir 1.20.4 sit in the plan and in test/conformance/<lang>/ci.json. A new test reads every ci.json and the plan and fails on a mismatch in either direction, and also refuses a registry that pins one field at two versions. dotnet is exempt by construction: the SDK version lives in .github/dotnet-version, both files carry a marker instead, and the test refuses a group that writes a version here.

6. timeout-minutes: 25 on a leg whose rows are held to 120 s. Set to 5, with the comment that says why: 120 s of control time plus about 25 s of checkout, clones and first build, so a row that reaches five minutes is a row that left the rule, and a ceiling wide enough to hide that is not a timeout.

7. ci.yml said certify.yml runs on every push to main. It has no push trigger (#669). The sentence says nightly and on demand, and the claim that followed it, that a regression fails on main minutes after the merge, is corrected to the next nightly or a dispatch.

8. Past-tense narration on this branch's own lines. "It used to sit in a group", "were 82 s", "which were 76 s" and "that job now measures" are present tense. docs/CONTRIBUTING.md no longer hardcodes a control count: it points at go run ./tools/negativecontrols check, which prints it.

And main again. The first run after the cold-read fixes went red on the enumeration gate, naming four controls main had landed while this branch was being read: tables-float-nan-negative-control, tables-maps-cook-check-negative-control, tables-lists-tool-cook-negative-control and tables-message-form-retain-negative-control. main is merged and each is measured and placed in the group whose corpus it already reads, at 1.6 s, 1.6 s, 0.3 s and 2.4 s off one warm build. No group changes tier and none goes near the rule.

Verified: go test ./tools/negativecontrols/... ./... green, make check green, and git diff origin/main...HEAD | grep '^+' | grep -c '—' is 0.

Closes #563

🤖 Generated with Claude Code

rowan-claude and others added 5 commits September 7, 2026 00:06
A negative control proves a gate is watching by breaking what the gate
watches and requiring the gate to go red. Each one in this tree already
refuses when its sabotage patches nothing, so a control whose pattern has
drifted off the line it aims at says so. Until now the only reader of that
refusal was `make test`, which runs after a merge.

This is the half that makes "every control" mean what it says.
tools/negativecontrols reads the Makefile and every file the Makefile
includes, collects each explicit target whose name carries
`negative-control`, and holds that set against make/negative-controls.json,
the plan the pull-request leg's matrix comes from. The test fails on a
difference in either direction: a control the makefiles define and the plan
does not carry, and a control the plan names and no makefile defines. An
exclusion needs a non-empty reason, so nothing leaves the leg silently.

The reader follows the tree's own include lines rather than a glob typed
here, and it reads the rule heads a makefile actually writes: several
targets on one head, a head continued over a backslash, `.PHONY` lines, and
`define` blocks and `:=` assignments that only look like rules. Both
readings of this tree agree at 159 controls.

The plan groups those 159 by what a runner has to install. Eight groups are
toolchain families; two more, wire-fuzz and message-form, are base-toolchain
families split out on measured cost.

Closes #563

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Twice on 2026-09-05 main's certification went red on a merge whose
pull-request run was 20 of 20 green, and one of the two was a stale sabotage
pattern: #562 rewrote the line the maps fit control aimed at, the sabotage
patched nothing, and the control refused where only certification could see
it. The controls were doing their job; nobody was reading them until after
the merge.

The leg is two jobs. negative-controls-matrix runs the enumeration gate and
prints the plan as a matrix. negative-controls fans out over it, builds
bin/schema and the generated tree once, and runs its group's controls in one
`make -k` invocation so a control that refuses does not hide the ones behind
it. Red comes from two places: make's exit status, and a scan of the log for
the controls' own refusal lines, which puts the sentence a reader needs into
the run's annotations instead of leaving it a thousand compiler lines deep.
The scan was held against the 159 real control logs and matches no passing
one.

The per-port groups install their SDK exactly as the conformance jobs above
do, keyed on the row's field rather than on a language name, so a group is a
row in make/negative-controls.json and no edit here.

Also a prerequisite this leg found by running a control on its own for the
first time: conformance-negative-control-absent's second half runs the
harness over a substituted registry naming the Go driver, which execs
build/conformance-go, and the target did not name it. Inside `make test` the
Go leg is already built, which is why the omission stayed invisible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
tables-block-layout-model-negative-control refused on the leg's first run
with "NEGATIVE CONTROL FAILED: C# went red, but not on the layout check",
and it was right: the C# build had failed on `CS0246: The type or namespace
name 'Blockhome' could not be found`, not on the layout check the control
exists to watch. The C# half overrides BlockGeneratedDir alone, so the
project's BlockHomeGeneratedDir keeps its default and the blockhome sources
have to be on disk, and the target named only bin/schema. The padding and
pitch controls beside it already name build/tables-generated-cs/.stamp; this
one now does too. Inside `make test` an earlier leg had generated those
sources already, which is why nothing said so until every control ran on its
own.

The reader's four golangci-lint findings, all mechanical: SplitSeq and
FieldsSeq for the range loops, CutSuffix for the line continuation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
rowan-claude added a commit that referenced this pull request Sep 7, 2026
Nothing under .github runs `make test`, and `make test` is the only thing
that runs the toolchain gate, so the gate this branch adds had no watcher on
the diff that changes it. The go-test job already runs three negative
controls by hand for exactly that reason, and this one belongs beside them:
it needs no toolchain of its own, since it points each registered pin at a
path that does not exist and reads the refusal, and it takes about a second.

Issue #660's sibling, #659, puts every negative control on pull-request CI
from one manifest; when it lands this step folds into that manifest's base
group and comes back out of here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
rowan-claude added a commit that referenced this pull request Sep 7, 2026
…p that is asked for (#599) (#660)

* make test refuses a missing pinned toolchain by name (#599)

`make test` passed over the JS, Dart, Elixir and Java legs when dist/ was
absent, and a red inside one of them rode a green run. The gate:

- every make/<lang>.mk registers TOOLCHAIN_LEGS, a TOOLCHAIN_PIN_<lang> and
  its own toolchain-<lang> target, one $(call toolchain_probe) per pin; the
  Makefile lists no language, as with every other registry here
- toolchain runs before the chain and refuses by name, printing the leg, the
  pin and the path the pin looked in
- SCHEMA_SKIP_LEGS names a skip on purpose, and every named leg says so
- SCHEMA_SKIP_LEGS naming something that is not a registered leg is refused
  before any pin is probed
- toolchain-negative-control points every registered pin at a path that does
  not exist and requires each leg to refuse by its own name, then names every
  leg and requires the same gate green with each skip printed
- the C# leg gets the DOTNET pin it never had, and every dotnet invocation in
  the tree reads it

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* A named skip reaches the conformance matrix and the two-language gates (#599)

A skip that stops at the leg loop is not a skip: `make test` still built the
skipped leg's conformance driver, still ran it, and still ran the C# half of
every block gate in the Makefile, on the same toolchain that is not there.

- harness run --skip <langs>: the Makefile fills it from SCHEMA_SKIP_LEGS, the
  harness prints each leg it passes over by name, and the reference leg is
  refused because every other leg compares against it
- each leg registers its conformance prerequisites through unless_skipped, so
  a named skip does not build the driver either
- the C# half of the block gates and the C#-only block controls print the skip
  and stand down, rather than dying on a dotnet the gate already named

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* CONTRIBUTING states the toolchain rule (#599)

The building section says `make test` refuses a missing pinned toolchain by
name and runs without a leg only when SCHEMA_SKIP_LEGS names the skip, with
the reason it is a rule rather than a preference. The registry section says
what a port with a pinned toolchain registers, beside every other list it
already registers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* The skip line goes out on the same rule as the matrix (#599)

errcheck: a write to the harness's own stdout is not a verdict this gate can
improve on, which is what the matrix write below already says.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* The skip list is read with SplitSeq (#599)

The repo's lint holds a range over a split to the sequence form, as readDrivers
beside it already does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* The negative control points every pin of every leg, not one per leg (#599)

A leg registers TOOLCHAIN_PINS_<lang>, the whole list of pins its
toolchain-<lang> target probes, and the control walks the list: each pin in
turn at a path that does not exist, the leg's other pins at one that
resolves, and the refusal must name that pin. Pointing one pin per leg left
the JAVAC, ELIXIRC and MIX probes watching nothing; deleting all three kept
the control green, which is a gate with no blade in the place the gate is
about. The Makefile header, the registry comment and CONTRIBUTING said
"every pin" already, so they are now true rather than aspirational.

The other pins point at a path that resolves while one is absent because the
bench this gate exists for has none of the toolchains installed: with the
leg's own defaults left alone, an earlier probe answers first and the refusal
names the wrong pin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* The named skip and the reference-leg floor are gated (#599)

skipSet and the "conformance SKIPS the <leg> leg" line had no test: the
harness grew a --skip argument and absence_test.go grew an empty one beside
it. Delete skipSet and every test in this package stayed green.

skip_test.go drives run through absence_test.go's fake corpus with the skip
filled in. The skipped leg's driver exits 1 and says so on stderr, which the
matrix folds into its failure list, so each test reads the skip's EFFECT and
not only its line: TestUnskippedLegRuns is the control that the leg would be
red if reached, the named skip turns it green and takes its matrix column
with it, a name no leg carries skips nothing, blanks and empty entries are
not names, and --skip cpp is refused before any driver runs because every
other leg compares against the reference.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* The toolchain gate's control runs on the pull request (#599)

Nothing under .github runs `make test`, and `make test` is the only thing
that runs the toolchain gate, so the gate this branch adds had no watcher on
the diff that changes it. The go-test job already runs three negative
controls by hand for exactly that reason, and this one belongs beside them:
it needs no toolchain of its own, since it points each registered pin at a
path that does not exist and reads the refusal, and it takes about a second.

Issue #660's sibling, #659, puts every negative control on pull-request CI
from one manifest; when it lands this step folds into that manifest's base
group and comes back out of here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* The toolchain gate names every missing leg in one run (#599)

The legs were the gate's own prerequisites, so make stopped at the first one
whose pin did not resolve: on this bench, with none of the five toolchains
installed, `make toolchain` said cs and nothing else, and a fresh clone
became a queue of install-one-run-again. The legs now go through sub-makes
instead, each leg's refusal passed through exactly as it printed it, and the
summary names them together with a SCHEMA_SKIP_LEGS line that carries the
skips already in force plus the newly missing legs, ready to paste.

The green line also stops claiming what it did not look at: with every leg
named in SCHEMA_SKIP_LEGS it said "0 of 5 registered legs probed, every
pinned toolchain resolves", and now says that none were probed because every
registered leg is named.

A leg that fails without printing the gate's own refusal has its stderr
shown; a leg that refuses by name does not also carry the sub-make's error
notice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* The gate's header comment reads as one paragraph again (#599)

Comment reflow only, no rule change: the previous commit left a half line
mid-sentence in the file header and opened the aggregate's comment with a
tautology.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* CONTRIBUTING states what the gate and its control now do (#599)

The building section said the refusal prints "the leg it would have skipped",
singular, which was true of a gate that stopped at the first missing pin and
is not true of one that probes every registered leg. It also did not say
where the negative control runs, which is the thing a contributor wants to
know before pushing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
rowan-claude and others added 4 commits September 7, 2026 07:08
The rule (Glenn, 2026-09-07): "if we have any CI that runs per-commit it
needs to finish 1 minute, 2 minutes max. Anything longer than that needs to
be nightly or manually triggered as needed." It is a rule about a JOB, and a
matrix row is a job, so every row of this leg is cut to fit it rather than
the leg as a whole being cut to fit it.

MEASURED on this branch's own pull-request run (34102397965), before this
change: base 8:57, wire-fuzz 7:13, cs 2:46, message-form 2:36, conformance
2:31, block-fuzz 1:59. Five of the eleven rows were over the rule and a
sixth was one second under it.

WHAT MOVED. The plan now carries 34 pull-request groups instead of 11, cut
where the measurement cuts them and not where the family names do:

  - base splits ten ways. The map gate is 17 controls at 4 s each on the
    author's machine, so it runs as base-maps and base-maps-keys; the
    conformance driver's two sabotage rebuilds are 45 s and 47 s of runner
    time each, so they are base-conformance and base-conformance-absence;
    the rest are the packet families, the list gate, the arm gate, the
    cook, the keyed controls and what is left.
  - wire-fuzz splits eleven ways, one control per job for ten of them.
    Each rebuilds the compiler under a source overlay and then fuzzes the
    sabotaged wire, 55 s to 75 s on the runner, so no two of them fit
    together; the four pinned-vector oracles are cheap and share the
    eleventh.
  - cs splits four ways. The C# layout check is the instrument for three
    block controls at 39 s, 31 s and 29 s.
  - block-fuzz splits in two, at 48 s and 33 s over two SDK installs.
  - conformance is gone as a group. Its one control had the bare
    `conformance` target as its prerequisite, which builds and runs all
    nine legs and needed all seven SDKs on one runner. The prerequisite is
    now `conformance-rust`, the rust leg and the derived manifest the
    control actually reads, and the control rides one SDK in rust-alloc.
    The soak and the audit keep `conformance`, deliberately: a NUMBER
    measured over a corpus whose matrix is red is a number about a defect,
    and this control is not a number.

ONE CONTROL DOES NOT FIT AT ANY GROUP SIZE, so it does not run on the pull
request at all: tables-message-form-negative-control drives 49 sabotage
rows one submake each and takes 124 s where the rule is 120. It is the
`nightly` group, and certify.yml runs that tier on the schedule it already
carries. Its four blades stay on the pull request in `message-form`.

So a group now names its tier. `when` is `pull-request` or `nightly`,
`matrix` renders one tier, and the enumerator REFUSES a group that names
neither: a control cannot leave the pull request without landing on the
nightly, and the package's test reads both workflows to prove each runs the
plan rather than a typed list.

The 35 controls main gained since this branch last merged it are in the
plan too, toolchain-negative-control from #660 among them, and the three
family umbrellas that would re-run a split family's other half join the
exclusion list with the reason. 194 controls, all of them in a group or in
an explained exclusion.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
tables-block-const-negative-control and tables-json-list-walk-negative-controls
landed on main mid-run, and the enumerator did what it is for: the leg went red
naming both rather than running 194 of 196. Both are cheap, 1.6 s and 3.7 s on
the author's machine, and both belong beside the block and JSON refusers
already in `base`, which measures 24 s as a group with them in it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
MEASURED on run 34115675206, the first run of the split leg: 52 of the 54
jobs were inside the owner's two-minute rule, and the two that were not are
tables-wire-fuzz-retain-negative-control at 140 s of control time and
`big-endian`, which is not this leg's job and is named in this file's header.

The retain control is over the rule ON ITS OWN, so grouping cannot save it:
it joins tables-message-form-negative-control in the `nightly` group, which
certify.yml runs. Its two siblings, the retain-class control and the retain
oracle, are inside the rule and stay on the pull request, so what moves is
that one pass.

Eight groups came in between 100 s and 111 s, which is a rule met by luck
rather than by design: `big-endian` was 1:49 when this file's header
measured it and is 4:19 today, on a job nobody changed. So base-maps,
base-maps-keys, base-arms, base-keyed and the oracle half of wire-fuzz are
each two groups now, cut where the measurement puts the weight rather than
where the family name does. 38 pull-request groups, 196 controls.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
rowan-claude and others added 6 commits September 7, 2026 07:58
… nightly (#563)

The marker `tools/negativecontrols` reads is `negative-control`, and three
controls the Makefile introduces as "Its NEGATIVE CONTROL" spell themselves
`-negative`: tables-big-endian-negative, tables-hostile-negative and
tables-pack-negative. The enumerator cannot see a name it does not match, so
"every negative control runs on every pull request" passed over three of them,
and two ran only inside `make test`. They are renamed into the convention and
placed.

tables-hostile-negative-control and tables-pack-negative-control go in a new
base-pack group: one pack corpus, one JSON engine and a `go build -overlay`
each, 19 s over the two in one invocation on a cold Go build cache with
bin/schema and the generated tree already built.

tables-big-endian-negative-control goes in the exclusion list. It is the body
of ci.yml's big-endian job, which runs it on every pull request already, and
issue #684 owns moving that job; a second s390x cross-compile on this leg
proves nothing the first does not.

tables-wire-fuzz-retain-negative-control leaves the nightly group for the same
list. Makefile:4428 gives it no recipe: it is an umbrella over the retain
oracle and retain class leaves, which wire-fuzz-message-oracle and
wire-fuzz-retain-class already run. The nightly tier is one control now, and
stays a group, because it is where the next control that outgrows the rule
lands.

Also: the negative-control leg's timeout drops from 25 minutes to 5, which is
the rule enforcing itself on a leg whose rows are cut to 120 s; ci.yml no
longer says certify.yml runs on every push to main (#669); and the past-tense
narration on this branch's own lines is present tense.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ot (#563)

The leg's target list is only the plan while the leg EXPANDS the plan.
TestTheLegRunsTheManifestAndNotATypedList greps three substrings, and a leg
that typed its own include list and left the old expression in a comment
satisfies every one of them. So the workflows are parsed. tools/negativecontrols
carries a reader for the block-YAML subset these files are written in, and the
new test asserts what each leg actually expands: `strategy.matrix` IS
`${{ fromJSON(needs.<matrix-job>.outputs.matrix) }}`, the leg `needs:` that
job, and that job's matrix output comes from the step that runs this tool.
Both tiers, ci.yml and certify.yml.

Proven red first. With ci.yml's matrix expression replaced by a hand-typed
three-row include list, TestTheLegRunsTheManifestAndNotATypedList still
passes, and the new test says:

  .github/workflows/ci.yml: the negative-controls job expands
  map[string]interface {}{"include":[]interface {}{...}} as its matrix, want
  "${{ fromJSON(needs.negative-controls-matrix.outputs.matrix) }}": a matrix
  written any other way is a target list this package did not enumerate

A marked rule head spelled through a variable or as a pattern rule is refused
by name rather than dropped. A dropped head is a control in no group, in no
exclusion and in no job, with every test in the package green, which is the
one outcome the package exists to prevent.

And the plan's toolchain versions are held against test/conformance/*/ci.json.
node 20, dart 3.13.2, java 21, otp 29.0.5 and elixir 1.20.4 are written in
both files and nothing joined them; a bump in one alone runs a negative
control against a runtime its own conformance leg no longer uses. dotnet is
exempt by construction: the SDK version lives in .github/dotnet-version and
the test refuses a group that writes one here.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The enumeration gate on the merge ref named all four rather than running 199
of 203, which is the gate doing its job. Measured off one warm build on the
author's machine and placed in the group whose corpus each already reads:

  tables-float-nan-negative-control            1.6 s  base
  tables-maps-cook-check-negative-control      1.6 s  base-maps
  tables-lists-tool-cook-negative-control      0.3 s  base-lists
  tables-message-form-retain-negative-control  2.4 s  message-form

No group changes tier and none goes near the rule: base 28 s over 24,
base-maps 20 s over 5, base-lists 35 s over 14, message-form 16 s over 5.

base-pack's own line is restated in the same convention the rest of the file
uses, off one warm build rather than off a cold Go build cache: 20 s over the
two, most of it the sanitized build of the hostile-value driver.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
On run 34120798793 the base-pack row was 81 s of control time in a 113 s job,
which is the band this branch already cut once: a rule met by luck rather than
by design. The two controls share no compile that matters. tables-pack's floor
is a `go build -overlay` and a byte comparison; tables-hostile's floor is the
SANITIZED build of the hostile-value driver, which nothing else in the leg
builds and which is most of the 81 s.

So they run as two rows, base-pack at 7 s and base-hostile at 14 s off one
warm build. The map gate's count in docs/CONTRIBUTING.md is four groups, not
two, which it has been since the second cut.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
tables-message-form-retain-negative-control joined the message-form group when
main landed it, so the two places that count the blades say "other blades"
rather than a number that moves whenever the family grows.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@rowan-claude
rowan-claude merged commit 95f8197 into main Sep 7, 2026
61 checks passed
rowan-claude added a commit that referenced this pull request Sep 7, 2026
…ly and on demand (#686) (#690)

Since #669 certify.yml has no push trigger, and the dispatch target for
the inline-budget gates is certify.yml, not ci.yml. The sentence said
both the old things; ci.yml's own header was corrected in #659.

Closes #686

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
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.

PR CI runs every negative control, so a stale sabotage pattern is caught before main

1 participant