ci: every negative control runs on pull requests, enumerated from the Makefile (#563) - #659
Merged
Conversation
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>
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>
… 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
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
sedaimed at, the sabotage patched nothing, and the control refused where onlymake testcould 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:
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-matrixruns the enumeration gate and prints the plan as a matrix.negative-controlsfans out over it, buildsbin/schemaand the generated tree once, and runs its group's controls in onemake -kinvocation, 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:andNEGATIVE CONTROL:, which is wherepatched nothinganddid not applylive). 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/negativecontrolsreads the Makefile and every file the Makefile includes, collects each explicit target whose name carriesnegative-control, and holds that set againstmake/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 bothci.ymlandcertify.ymland 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
includelines rather than a glob, and reads the rule heads a makefile actually writes: several targets on one head, a head continued over a backslash,.PHONYlines, anddefineblocks and:=assignments that only look like rules. 196 controls, up from 159 when this branch opened, all of them enumerated rather than typed.Measured, per job, on this branch's own run
Run 34116264000,
pull_request, head037becee. Every one of the 38 negative-control jobs is under 120 s, wall time fromstartedAttocompletedAt:wire-fuzzwire-fuzz-wide-textwire-fuzz-arm-widthwire-fuzz-message-oraclewire-fuzz-indexbase-listswire-fuzz-arm-terminatorbase-cookwire-fuzz-message-textbase-maps-orderwire-fuzz-lengthcs-block-pitchbase-conformancewire-fuzz-retain-classblock-fuzz-maximumwire-fuzz-message-blobblock-fuzzwire-fuzz-blob-nodebase-mapsbase-keyedelixirbase-maps-keysbase-shared-nodecs-block-layoutbase-conformance-absencebase-maps-depthcsbase-armsjavadartcs-cookbase-packetbase-keyed-shiftbasejsrustmessage-formrust-allocnegative-controls-matrixEvery 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-endianat 263 s. It is 1:49 inci.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.
base, the six per-port groups,block-fuzz,conformance, andwire-fuzzandmessage-formsplit out ofbaseon 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.wire-fuzz-retain, and eight sat between 100 s and 111 s.big-endiangoing 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:
wire-fuzzis 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.csis four groups.conformanceis gone as a group. Its one control had the bareconformancetarget as its prerequisite, which builds and runs all nine legs and needed all seven SDKs on one runner. The prerequisite is nowconformance-rust, the rust leg and the derived manifest the control actually reads, and the control rides one SDK inrust-allocat 39 s. The soak and the audit keepconformancedeliberately: 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 inmessage-form.tables-wire-fuzz-retain-negative-controlwas 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
whenfield,matrixrenders one tier, and the enumerator refuses a group that names neither.certify.ymlruns 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 readsSABOTAGEfrom 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, whichwire-fuzz-message-oracleandwire-fuzz-retain-classrun between them.tables-big-endian-negative-control, the 263 s body of thebig-endianjob, 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-absentdid not namebuild/conformance-goas a prerequisite, though its second half execs it. Insidemake testthe Go leg is already built, which is why the omission stayed invisible. Fixed here.toolchain-negative-controlfrom 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
whydescribed an umbrella as a pass.Makefile:4428givestables-wire-fuzz-retain-negative-controlno recipe: it namestables-wire-fuzz-retain-oracle-negative-controlandtables-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 toexcludedwith the sentence the other umbrellas carry, and the claim is corrected where it is repeated, inci.yml's leg comment and indocs/CONTRIBUTING.md. Thenightlygroup holds one target and stays a group.2. Three negative controls the naming convention hid. The marker
tools/negativecontrolsreads isnegative-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 insidemake test. All three are renamed into the convention, with every reference moved (Makefile,docs/PORTING.md,ci.yml,compiler/porting_test.go), and placed:tables-big-endian-negative-controlexcludedbig-endianjob, 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 twicetables-hostile-negative-controlbase-hostilegrouptables-pack-negative-controlbase-packgroupThey started as one
base-packgroup 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 ago build -overlayand a byte comparison, andtables-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. Sobase-packis the pack control at 7 s andbase-hostileis 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.v3is 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, thatstrategy.matrixIS${{ fromJSON(needs.<matrix-job>.outputs.matrix) }}, that the legneeds: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,TestTheLegRunsTheManifestAndNotATypedListstill passed:and the new test said:
The change was reverted;
ci.ymlis byte-identical to what it was before the experiment.4. A marked head carrying
$or%was dropped in silence.makefile.goskipped 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.PHONYsubstitution 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 everyci.jsonand the plan and fails on a mismatch in either direction, and also refuses a registry that pins one field at two versions.dotnetis 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: 25on 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.ymlsaidcertify.ymlruns 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.mdno longer hardcodes a control count: it points atgo 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-controlandtables-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 checkgreen, andgit diff origin/main...HEAD | grep '^+' | grep -c '—'is 0.Closes #563
🤖 Generated with Claude Code