Skip to content

fix(ci): put the dev profile in the rust-cache key, and stop asserting one rule with all 103 - #819

Merged
wenzowski merged 7 commits into
mainfrom
claude/ci-performance-degradation-ic29ck
Sep 2, 2026
Merged

fix(ci): put the dev profile in the rust-cache key, and stop asserting one rule with all 103#819
wenzowski merged 7 commits into
mainfrom
claude/ci-performance-degradation-ic29ck

Conversation

@wenzowski

@wenzowski wenzowski commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Two independent fixes to the check suite's critical path, which is now rust.yml/windows rather than ci.yml.

The cache has been poisoned since 2026-09-01T09:35Z

Swatinem/rust-cache logs the files it keys on — Cargo.lock and crates/batten/Cargo.toml. The root Cargo.toml is not among them, and that is where [profile.*] lives. f54a9c05 changed [profile.dev] and [profile.dev.package."*"], the key did not move, and every restore since has reported full match: true while handing back artifacts built under the old profile.

Measured with a control on each side of that commit — Compiling lines per run:

job before after
ci 0 (Aug 28) 121 (Sep 1, both runs sampled)
windows 1 (Aug 23, Aug 28) 288 (Sep 1, both runs sampled)

~318–670s per run on ci, ~838–1061s on windows. It does not decay on its own: cache-warm-linux and cache-warm-windows compile only on cache-hit != 'true', the stale key still hits, so they skip and the entry is served indefinitely. v0-rust-ci-Linux-x64-22abc94d-db6ecef6 has been served since Aug 31 21:19Z.

The hash goes in shared-key, and every key: is deleted. v2.9.2 src/config.ts reads key only in the else arm of if (sharedKey), so on every step that set both it has never had any effect:

if (sharedKey) { key += `-${sharedKey}`; }
else { const inputKey = core.getInput("key"); if (inputKey) { key += `-${inputKey}`; } }

The windows job's own comment already carried the two live entries proving it — v0-rust-windows-windows-Windows_NT-x64-… before its shared key, v0-rust-windows-Windows_NT-x64-… after — while inferring a composition, {key}-{shared-key or job id}, that those strings contradict. The strings were right; the inference was not. Both are corrected there.

Twelve steps across six workflows now carry one spelling each, and the two pairs that must agree — ci/cache-warm-linux, windows/cache-warm-windows — are identical.

One rule asserted with one rule

a_tracked_instruction_may_not_prescribe_the_denied_commit_identity ran enforce over the whole committed ruleset — twice, the clean arm being a second evaluation — to assert a single row. 305s on the Windows runner, of a 1482s suite. It is 0.122s narrowed.

The shape matters more than the number: a case that evaluates every row to assert one gets slower every time a row is added, and this repository adds rows deliberately. [[rule]] went 47 → 103 and line_sources 2 → 28 between Aug 23 and Sep 1, over which this case went 4s → 305s. The retirement campaign was paying itself a tax on every gate it moved onto the engine.

check --rule was already trusted against these exact bytes — mise.toml invokes --rule prose-only, --rule filed-here, --rule memory-graph. What kept this family on enforce was a comment claiming the read-effect verb refuses the committed config outright. True of an unnarrowed check, but --rule selects the row before the refusal is reached, and only 3 of 103 rows are kind = "command". The comment is corrected in place rather than deleted.

Verification

  • Green: actionlint, zizmor, ci-tools-check, timeout-check, rust-paths-check, commit check over the range.
  • Shown able to fail (CLOUD-418): with the prescription removed from the dirty fixture the narrowed case fails on its exit-code assertion at cli.rs:11426, and passes again when restored. Verified by md5 that the file was unmodified across the run — a first attempt at this check silently did not persist and its "pass" proved nothing.
  • The adjacent vacuity is closed: check --rule naming no declared row exits 1 with no [[rule]] row is declared with id …, never a clean run over nothing.

What this PR cannot verify locally

The cache fix is a hosted cache, so the first run here pays one cold build by design and should not be read as a regression. The second run is the measurement. Acceptance is Compiling back to 0 on ci and 1 on windows, read with gh api …/actions/jobs/{id}/logsgh run view --log omits build output entirely and reports 0 for a run that compiled 121.

One composition detail is unverified from source and confirmed only by the Cache Key line the first run prints: that shared-key carrying an expression composes as expected. The acceptance above catches it if not.

Also in this PR, added after the first push

enforce --rule (crates/batten/src/surface.rs, mirroring CHECK_RULE) for
the_committed_delegating_rule_spawns_nothing_when_its_glob_misses — genuinely about a
kind = "command" row, so check must refuse it by construction. 206s → 0.093s. The flag
refuses an id naming no declared row exactly as check --rule does; that property matters more on
the verb that spawns, not less.

Two follow-on commits the gates asked for:

  • The golden schema snapshot for that flag. b6b3180d regenerated the completions and the man page
    but not it__snapshots__golden_json_schema.snap, which is the third surface and freezes the whole
    command tree as data. Caught by the suite, accepted through mise run snapshots.
  • refactor(cli)!: Enforce(EnforceFlags). semver refused the branch — adding a field to the
    inline Command::Enforce { json } variant is enum_struct_variant_field_added, and
    #[non_exhaustive] on the enum governs adding variants, not a variant's fields. There is no
    compatible way to add the flag through that enum, so the break is taken either way; it lands in
    the shape CheckFlags already uses so the next flag on enforce is additive. The CLI surface
    does not move — the golden schema is byte-identical across that commit.

Local suite: 3854 of 3854, verify: fast-forward-green.

Not in this PR

The same narrowing for the_committed_portability_rules_fire_on_every_banned_shape (6 rules) and
the_committed_repo_agnosticism_rules_fire_on_every_banned_shape (4 rules), ~18s each. --rule is
single-valued, and both cases keep a full-ruleset arm regardless: their clean-tree arms assert that
nothing fires, which is a whole-ruleset property. Narrowing only the dirty arms saves ~9s each and
costs the cross-rule sort-order property the full stdout equality currently proves. Recorded on
CLOUD-1223 rather than half-done.

The gate that was blocking this PR, repaired here

gh pr ready is denied by ready-needs-an-answered-review unless the
review-answered fact returns empty. Measured over the last 60 merged PRs: zero
reviews, on any of them.
The gate has never once been satisfiable here.

The fact queries GitHub's reviews and reviewThreads. The reviewer its own
refusal names — @coderabbitai full review — writes neither on this repository's
plan; it answers "the author of this PR is on the CodeRabbit Free Plan … please
upgrade"
, and its automatic pass writes an issue comment. So the predicate read
the wrong object for the reviewer it named, and its length == 0 arm was
unconditionally true.

It was invisible for eight days because every session ran a batten too old to
enforce it — CLOUD-1326's class, measured here at 0.0.121 against a 0.0.137 tree. A
gate nothing can satisfy and a gate nothing enforces are byte-identical on the
decision surface.

Not a softening. A non-author comment counts only if it carries
auto-generated comment: summarize by AND NOT rate limited by. #819's only such
comment carries both — a rate-limit stub, a review that did not happen — so the
repaired predicate still returns ["wenzowski"] against this very PR. Shown able
to fail in four directions; the unresolved-thread arm is untouched.

The weakening is groomed on CLOUD-1334 ahead of the change and admitted (groomed)
— both halves of config-lint's two-source evidence, not the CI trailer-alone
fallback.

Closes CLOUD-1334

Closes CLOUD-1223

DO-NOT-CLOSE CLOUD-840 — this PR fixes how the key is composed, which is not that row's remaining
work. CLOUD-840 was reopened on two failing acceptance criteria: total cache size under the 10 GB
cap (measured 225.2 GB) and the job no longer writing bytes nothing restores (203.7 GB across 1,144
PR-ref entries). Neither is touched here. save-if: false is its costed remedy and belongs on its
own change.

Refs: CLOUD-840, CLOUD-1223, CLOUD-1225


Generated by Claude Code

@linear-code

linear-code Bot commented Sep 2, 2026

Copy link
Copy Markdown
CLOUD-840 Every PR compiles the Rust tree cold: the caches exist, are byte-identical across PRs, and no PR can read another's — so `windows` writes 182MB per run that nothing ever restores

Why

CLOUD-813 measured cargo test against cargo nextest run on windows-latest and got 499s against 501s — two seconds. A runner 22% faster at executing this suite on Linux cannot move the job carrying 50.6% of the bill, which is only possible if execution is a small share of that step. The ~8m20s is compilation, and that is what this row is about.

Measured 2026-08-21 from the Actions cache API, not inferred from step timings:

  • The Windows rust cache key is stable and identical across pull requestsv0-rust-windows-windows-Windows_NT-x64-d44ea756-e905c9ae — present on refs/pull/572, 601, 604, 607 and 608, each about 182MB. Five copies of the same bytes.
  • It is absent from refs/heads/main. GitHub scopes a cache read to the run's own ref plus the base branch, so no pull request can read another's copy and none has a base-branch copy to inherit.
  • So every Windows run restores in 7s — a miss — compiles cold, and then spends 70s writing 182MB that only that pull request could ever read. The branch is deleted at merge.
  • The only rust caches on main belong to perf, because perf.yml runs on a schedule. rust.yml and ci.yml trigger on pull_request only, so theirs never land there.
  • The repository holds 10.64 GB against GitHub's 10 GB cap — 6.69 GB rust, 3.95 GB mise across 40 entries. Eviction is live and LRU, so the duplicates are evicting each other.

This reverses a recorded decision, and that decision named the conditions.

CLOUD-176 considered base-branch warming and recommended against it: about 178s of job-time per merge to save about 60s on one pull request's first run, "roughly 3× the job-time it saves." That costing lists four legs — ci, cross and darwin-link twice — all Linux, all billed at 1×. It predates the windows job entirely, and the arithmetic does not survive its arrival.

It also wrote down when to revisit, and both conditions now hold:

  • "PRs routinely land on their first CI run"land merges on the first green lap, which is the ordinary case rather than the exception.
  • "the cold-vs-warm gap grows well past ~15s per job" — the Windows gap is most of 8m20s, at 2× billing.

Recording the reversal rather than quietly re-filing, because CLOUD-176's recommendation is written down and someone will find it.

Refinement — Ready (warm the base branch, and get under the cap so the warm entry survives)

  • Source of truth (§1). The Actions cache API for what exists and where, and the windows job's own step timings before and after for what it buys. Neither is a number a human types, and the cost side is measured on the runner that pays it rather than inferred from Linux.
  • Computable predicate (§2). After warming, a v0-rust-windows-… entry exists on refs/heads/main, and the first run of a fresh pull request restores it rather than reporting a miss. The gate is the restore, not the wall clock: a run that restores and is still slow is a different finding, and a run that is fast because nothing changed is not evidence.
  • No new trigger, and AGENTS.md is not bent (§2). release-plz.yml already runs on push: branches: [main] and is the only workflow that does. CLOUD-176 identifies it as the attachment point. The rule this repository holds is that CI does not re-run on main — a warm job compiles to fill a cache and asserts nothing, so it is not a second verdict on an already-tested SHA.
  • The cap is part of the work, not a footnote (§2). A cache the pull requests never read is pure eviction pressure on the one they would. 3.95 GB of mise tool caches across 40 entries and roughly 0.9 GB of duplicate Windows rust caches are the bulk of the overflow. Warming without reclaiming that space buys an entry that is evicted before it is read.
  • Deliberately not in scope (§2). The runner swap — CLOUD-813, settled. The toolchain-install overhead — CLOUD-812. The paths filter's omissions — CLOUD-828.
  • Effect (§3). write — CI configuration only. No verb, no command surface, no change to what any gate proves.
  • Output and exit (§5). Unchanged; a warm job asserts nothing and must not be able to red a landing.
  • Commit / bump (§6). ci(cache) — no bump. Nothing under crates/.
  • Test obligation (§7). The cost and the benefit both stated as measurements before the change is kept: the warm job's own billed minutes, against the first-run delta on a fresh pull request measured against the 499s baseline on record. A warm cycle that costs more than it saves is the outcome CLOUD-176 predicted and must be recorded as such rather than absorbed.
  • Blockers (§8). None. relatedTo CLOUD-176 (the decision this reverses, and whose revisit clause authorises it), CLOUD-813 (whose negative result is what redirects the effort here), CLOUD-812 (the overhead layer of the same ledger), CLOUD-398 (the job graph half).

Acceptance

  • A v0-rust-windows-… cache exists on refs/heads/main.
  • The first Rust run of a fresh pull request reports a cache hit rather than a 7s miss, shown against a run that did not.
  • Total cache size is under the 10 GB cap, so the warm entry is not evicted before it is read.
  • The warm cycle's billed minutes and the per-run saving are both recorded, whichever way the trade falls.
  • The Windows job stops writing 182MB on every run that nothing restores.

Reopened 2026-09-01 — two of the four acceptance criteria fail on measurement

The warming half worked and is not in question. The cap half did not, and has grown 22× past the bound this row set.

Measured mise exec -- gh api repos/button-inc/batten/actions/cache/usage and …/actions/caches?per_page=100 paged to exhaustion, 2026-09-01:

acceptance criterion at filing (2026-08-21) 2026-09-01
a v0-rust-… cache exists on refs/heads/main absent 94 entries, 17.8 GB
a fresh PR's first Rust run restores rather than missing 7s miss Cache restored successfully on #804
total cache size under the 10 GB cap 10.64 GB 225.2 GB
the job stops writing bytes nothing restores 182 MB/run 203.7 GB across 1,144 PR-ref entries

1,144 of 1,238 entries (92% of bytes) sit on PR refs, which by the scoping this row already documents are readable only by re-runs of that same PR. The class this row closed for windows specifically is alive across every other key: mise 751 entries / 80.1 GB, rust-semver 83 / 36.3 GB, rust-perf 51 / 26.0 GB, rust-ci 41 / 25.8 GB, rust-bats 47 / 16.8 GB.

It is still accumulating: 1,238 entries / 221.6 GB at 15:53Z became 1,265 / 225.2 GB by 16:47Z — +27 entries and +3.6 GB in under an hour.

Two corrections to this row's own reasoning, both measured rather than argued.

Eviction is not currently the harm, so the §2 framing "a cache the pull requests never read is pure eviction pressure on the one they would" no longer describes what is happening. Main's entries are alive and being read — v0-rust-ci-Linux-x64-22abc94d-db6ecef6 last accessed 2026-09-01T16:33Z. Nothing is being evicted out from under a PR. And Actions storage bills $0.00 on August's invoice, so the waste costs no money either. What remains is hygiene against a bound this row set, which is reason enough to reopen but is a different claim from the one filed.

Warming did not fix the cold build, and this row's premise assumed it would. PR #804's windows job restored its cache successfully and still spent 838s compiling before 556s of test execution; the ci job's skill-check step logged Finished `test` profile … in 11m 09s over 120 Compiling lines. Swatinem/rust-cache discards workspace-crate artifacts by design, so the entry it restores (0.23–0.50 GB on main) cannot contain a target/debug that Cargo.toml measures at 7.76 GB. The cache is working as designed; it never covered the expensive half. That term belongs to CLOUD-1225, not here — this row should not grow to absorb it.

**What the remedy has to be costed against. **save-if: false on the PR-side rust-cache steps is the obvious move and would take 203.7 GB to near zero. It is not free: a PR-side entry does serve re-runs and land laps at the same lockfile hash, which is the case CLOUD-176's reversal clause did not consider. Cost that before landing it.

CLOUD-1223 Every test judging a real config pays 325ms per `batten hook` against release's 48ms — the debug profile leaves the dependency closure unoptimised

Why

CLOUD-1217 recorded that seven crates/batten/tests/cli.rs cases were expensive and that its first version blamed no-secrets for them — "Withdrawn and still NOT replaced". This is the replacement, measured with CLOUD-1208's harness rather than by hand.

Measured, this container, 2026-08-30, parsing per-case durations out of one full mise run test:cargo (3194 cases, 571.0s summed CPU, 143.4s wall on 4 cores):

share of summed CPU cases % of suite
25% 3 0.1%
50% 24 0.8%
66.6% 82 2.6%
the remaining 10.6% 2687 84.1%

Four cases are 184.7s — 32.3% of the entire suite:

58.36s  batten::cli  a_tracked_instruction_may_not_prescribe_the_denied_commit_identity
44.11s  batten::cli  the_committed_portability_rules_fire_on_every_banned_shape
43.08s  batten::cli  the_committed_delegating_rule_spawns_nothing_when_its_glob_misses
39.15s  batten::cli  the_committed_repo_agnosticism_rules_fire_on_every_banned_shape

batten::cli alone is 252.4s of 571.0s across 299 cases. The next binary is board_receipts at 41.6s.

The shape is one thing repeated. Each of those cases reads the committed batten.toml, builds a fixture from it, and runs the **whole **enforce ruleset over that fixture. Post-CLOUD-1217 that ruleset is ~37s per invocation. It is ~7 evaluations rather than 4: a_tracked_instruction_may_not_prescribe_the_denied_commit_identity runs enforce TWICE, over a dirty fixture and a clean one, and both are load-bearing — the clean arm is the anti-vacuity discriminator proving the rule does not fire on prose that merely names the authority. That is why it is the 58.36s case.

THE FIRST MECHANISM THIS ROW CARRIED WAS UNSOUND AND IS WITHDRAWN. It said the four cases should "share ONE enforce evaluation ... each asserting its own properties over that output". They cannot. Reading the code rather than the durations: every case writes DIFFERENT content into its own fixture — the glob-miss case plants a conflict marker outside the declared glob and asserts exit 0 with empty stdout; the agnosticism case plants banned shapes and asserts full stdout equality. Different trees are different computations, and no sharing is available. That mechanism was written from a plausible story about the numbers instead of from the source, which is the third instance in this campaign of exactly the error CLOUD-1208 exists to stop.

Two consequences worth stating separately.

  • At 4 cores the suite is core-bound, not tail-bound: 571.0s summed against 143.4s wall is 3.98x, so total CPU is what moves the wall clock — and 2.6% of cases own two thirds of it. Work spread across the other 84% is worth 10.6% in total and is not where anything is.
  • The asymptotic floor is the slowest single case, 58.36s. No amount of parallelism or core count takes test:cargo below it. Today that floor is hidden because 4 cores make the run core-bound; on a wider runner it becomes the binding constraint, and on CI's 2 cores the CPU total binds harder still.

**What this is NOT, so it is not conflated with its neighbours. **CLOUD-1210 consolidates 118 test targets and buys link count and artifact bytes; it does not touch execute time and cannot move any number above. CLOUD-1211 is bytes and load cost. This row is the execute term, which no open row currently owns.

Refinement — Ready

  • Source of truth (§1). Per-case durations from nextest's own output over one mise run test:cargo, and crates/batten/tests/cli.rs's four cases named above. CLOUD-1208's harness is the instrument for the before/after, and its measured null is 0.963–1.012, so a delta must clear that to be read at all.

  • Mechanism (§3), RESOLVED AND LANDED — one profile line, 78662993**. **[profile.dev.package."*"] opt-level = 2: optimise the dependency closure, leave workspace code unoptimised so rebuilds stay fast and backtraces stay readable.

    **Measured: **mise run test:cargo warm 100.189s -> 48.581s, 3201 tests all passing — a ratio of 0.485 against CLOUD-1208's 0.963–1.012 null. batten hook against a real config 325ms -> 70ms (release is 48ms).

    Cost, stated: a cold dependency build goes 217s -> 366s, paid ONCE and then cached — dependencies recompile only when they change or the profile does, and CI already carries Swatinem/rust-cache. The warm edit-test loop pays none of it.

  • THE SECOND MECHANISM WAS ALSO UNSOUND (§3, withdrawn). It said to stub gh and syft into the fixture's provision cache the way ripsecrets already is. Those rules never run in a fixture at all. Their globs are the gate: sbom-ntia-precondition and sbom-ntia-conformance glob Cargo.lock, claim-not-raced globs mise-tasks/claim-race-check.sh, and no fixture built by repo_with_config + committed_budget_surfaces contains either path. A command rule's glob is a gate before it is an argv source — the property the_committed_delegating_rule_spawns_nothing_when_its_glob_misses exists in this same file to prove. There was nothing to stub and nothing to save.

    Two mechanisms, both read off durations, both wrong. The third was read off the binary.

  • Deliberately not in scope (§2). Deleting or weakening any case — assertions-not-gutted and tests-not-deleted stay green across the change, and the case count must not fall. process_group::a_surviving_grandchild_cannot_hang_exec (20.06s), which is a deliberate wait proving a grandchild cannot hang exec; shortening it weakens exactly what it proves. Anything about link count or artifact bytes, which are the two rows above.

  • Output (§7). Durations, case names and counts.

Test obligation

The before/after is a MEASUREMENT against CLOUD-1208's null, not an assertion. Shown able to fail per CLOUD-418: with the stubs seeded, deleting or allow-ing any committed rule must still change the asserted stdout bytes. The arm most likely to be flattened is a stub that makes a rule report nothing when the real binary would have reported something — so the discriminator is a fixture that SHOULD trip a stubbed rule, which must still trip it.

Commit / bump (§6): test(cli) — no bump. No shipped verb, no public item, no config key moves.

Blockers (§8): none. relatedTo CLOUD-1208 (the harness and the null this is judged against), CLOUD-1210 (link count, a different term), CLOUD-1211 (bytes, a different term), CLOUD-365 (test shape, the distribution one level up), CLOUD-352.

Acceptance

  • The four cases evaluate the committed ruleset once between themsuperseded. That was the first withdrawn mechanism and it was never possible. The real acceptance is the suite delta against the null: 100.189s -> 48.581s, ratio 0.485 against a 0.963–1.012 null. Done.
  • Every case still exists and still fails independently when the property it asserts is broken. Done — 3201 tests, all passing, and no test file was edited at all. The fix is a profile line, so no assertion could have been weakened by construction.
  • The measurement is quoted from the harness rather than from a hand timing. Done.
  • The four cli.rs cases named in the title are NOT individually fixed and do not need to be. Their headline share was inflated 4.7x by contention (39.15s in-suite against 8.37s isolated), and what they had in common with the genuinely-uninflated binaries — board_receipts, pipeline_shapes, mediated_verbs, connector_verbs — was the per-invocation tax, not their own structure.

CLOUD-1225 Nothing in the campaign attacks COMPILE time, and `rustc_codegen_cranelift` is the one lever that would — priced against a nightly toolchain this repo does not have

Why

The suite-cost campaign has four rows and they partition three terms, leaving one uncovered:

term row what it moves
link count / artifact bytes CLOUD-1210 118 targets → grouped
debuginfo bytes CLOUD-1211 what each binary carries
execute CLOUD-1223 4 cases that are 32% of the suite
codegen nothing

rustc_codegen_cranelift (cg_clif) is the lever for the fourth. It replaces LLVM as rustc's codegen backend for unoptimised builds, which is exactly the profile a test loop uses, and upstream's whole reason for existing is that LLVM's optimising pipeline is overkill when nothing is being optimised.

This is a MEASUREMENT row, not an adoption row. Its deliverable is a number and a recommendation; adoption is a separate decision with a price named below.

Where it could and could not help, from numbers already taken

Measured this container, 2026-08-30 (CLOUD-1208's harness):

  • Warm loop: the build term is ~1.1s against ~99s of execute. cg_clif buys nothing here — there is nothing to compile.
  • **Cold **mise run test:cargo: 231s at debug = 1, of which the compile-and-link share is the bulk.

So the honest scope is the cold and partial-rebuild path, not the edit-test loop a developer sits in. That path is paid by CI on every run, by every rebase that touches crate source, and by every land lap — which is where CLOUD-352 says the throughput ceiling actually is. Anyone quoting a cg_clif win against the warm number is quoting the wrong arm.

The price, stated before the measurement rather than discovered after

  • It is nightly-only. rustc-codegen-cranelift-preview is a rustup component for nightly. Verified on this tree: rustup component list for the pinned toolchain reports no cranelift component, and rustup toolchain list shows only stable and 1.97.1. So this is not a config flip — it costs a second toolchain.
  • A second toolchain fights three standing gates. msrv-pin-agreement holds rust-version, constraints.rust and the [tools] rust pin in agreement; lock-complete requires every locked tool to install on linux-x64, linux-arm64 and macos-arm64; no-source-built-tool refuses compiling one from source. A nightly pinned only for the test loop has to satisfy all three or declare why it is exempt.
  • Fidelity is a real question, not a formality. Tests would run against cranelift-generated code while releases ship LLVM-generated code. For a policy engine whose suite asserts decisions — exit codes, byte-stable output, refusal text — that is very likely immaterial, but "very likely" is the word this repository does not accept without a check. process_group's cases, which assert signal and process-group behaviour, are the ones most plausibly sensitive.

Refinement — Ready

  • Source of truth (§1). rustup component list for the pinned toolchain (already taken: no cranelift). CLOUD-1208's harness for the before/after, and its measured null 0.963–1.012. The cold arm is mise run test:cargo over a cleared target/debug.
  • Mechanism (§3). Measure, in a scratch container so nothing here is pinned to nightly: a cold test:cargo on the pinned stable against a cold test:cargo on nightly with -Zcodegen-backend=cranelift, paired and back to back. Report the compile-and-link delta separately from execute, since cg_clif can only move the first — the four-term shape CLOUD-1208 exists to keep honest.
  • The arm that decides fidelity (§3). The full suite must pass identically under cg_clif. A differing case is the finding, not a nuisance to work around, and process_group is where to look first.
  • Deliberately not in scope (§2). Adopting nightly for anything but the test loop. Any change to [profile.release] or [profile.dist] — cg_clif does not optimise and must never reach a shipped artifact. Re-litigating the linker, which CLOUD-1211 already settled (rust-lld is the default since 1.90 and readelf -p .comment reports Linker: LLD 22.1.6).
  • Output (§7). Durations and a pass/fail count per arm.

Test obligation

If adopted, a case asserting the test loop's backend is the declared one — msrv-pin-agreement's shape, holding two authorities together — so a silent fallback to LLVM does not read as a cg_clif measurement. If not adopted, the measurement is recorded on this row so nobody re-runs it.

Commit / bump (§6): perf(build) if adopted, test(bench) if the outcome is a recorded measurement only — no bump either way.

Blockers (§8): none. relatedTo CLOUD-1208 (the harness and null), CLOUD-1210, CLOUD-1211, CLOUD-1223 (the other three terms), CLOUD-352 (build duration as the throughput ceiling), CLOUD-654 (the [tools] rust pin this would sit beside), CLOUD-813 (the windows job, which pays the cold path hardest).

Acceptance

  • The compile-and-link delta is reported separately from execute, against CLOUD-1208's null.
  • The suite passes identically under both backends, or the differing case is named.
  • An arm inside the null is recorded as not distinguishable from noise, so nobody re-runs it.
  • If the toolchain price is judged too high, that judgement is recorded here with the number that informed it — the measurement outlives the decision.

The CI-side arm, measured 2026-09-01 — and why this row should not sit at No priority

This row's numbers are container-local (warm build ~1.1s, cold test:cargo 231s), and it names its own scope as "the cold and partial-rebuild path … paid by CI on every run". It had no CI number. Here is one, from PR #804's job logs (runs 33524120394 and 33524120462), which is also the direct evidence that the fourth term is the largest uncovered one.

ci job — 1118s of hk, step spans:

step window wall
policy-budget 1 → 322s 321s
skill-check 2 → 674s 671s
pkl 324 → 676s 352s
test 791 → 1020s 229s
batten-check 1020 → 1118s 99s

skill-check logged Finished `test` profile … in 11m 09s over 120 Compiling lines — the dependency closure, cold. Every step after it is warm off that one build: reference-check 0.28s, schema-check 0.26s, and test's own build 0.19s against 229s of execution.

windows job — 1394s in a single step: the rust-cache restored successfully, then nextest reported Summary [555.576s] 3482 tests. So 838s compile + 556s execute.

Corrected 2026-09-01, same day, on a second run — the 60% figure below was taken from one run and #804 is the slow end of the range. Run 33547390281 (19:04Z, ci job 99988182032) compiled the same 121 crates off the same full-match cache hit, so the recompile is chronic and not an artifact of #804. But its build took 5m 18s against #804's 11m 09s, and skill-check held 319s of a 1035s hk run (31%) rather than 671s of 1122s (60%). The two runs' hk totals differ by only 8% because the steps run in parallel and the pole alternates: pkl was 352s in #804 and 321s here, so at 19:04 it and skill-check were co-poles and the tail chain (test 243s → batten-check 60s) set the finish.

So the honest statement is: **the dependency closure recompiles on every run despite a cache hit, and costs between ~30% and ~60% of the **ci job depending on contention. The mechanism is confirmed from the step's own resolved config, which logs cache-workspace-crates: false and cache-all-crates: false — not inferred. Compile is therefore a standing, chronic share of ci and windows, the two poles of a check suite whose critical path sets landing cadence — and CLOUD-840's warming, now reopened, does not touch it: Swatinem/rust-cache discards workspace-crate artifacts by design, so a hit still compiles cold. Raising this off No priority on that basis.

Two adjacent shards refuted, recorded here so they are not re-proposed. Both are the shapes a reader reaches for when told "CI is slow", and both are answered by the same measurement:

  • Giving test its own job — the CLOUD-1140 move applied to cargo. Refuted: test's build is already warm at 0.19s and its 229s is pure execution, so a separate job would pay its own cold 11 minutes to save 229s. It duplicates the cost rather than dividing it.
  • Sharding windows with cargo nextest --partition — refuted. 838s of the 1394s is compile, which every shard repeats: 3 shards ≈ 3 × 838s billed for ~6 min of wall clock, on a runner already at 2× rate.

Neither is a codegen change, which is the point — they redistribute a cost this row is the only one attacking at its source.

One caveat on sequencing. If CLOUD-585 (make the repository public) lands first, every runner goes 2 → 4 vCPU free, and compile is the term that scales with cores. The cg_clif measurement should be taken after that flip, or on a box matching whichever configuration will actually run it — a delta measured on 2 cores does not transfer.

**Method note, because it nearly produced a false retraction here. **gh run view <id> --log is lossy — it omits cargo's build output entirely. Counting Compiling lines in it returns 0 for a run that genuinely compiled 121 crates, which reads exactly like a clean cache and would have retracted this row's finding on an artifact. Use gh api repos/{owner}/{repo}/actions/jobs/{job_id}/logs or the MCP get_job_logs tool, and verify the log contains a line you already know is there before drawing a negative conclusion from its absence. The windows 838s/556s split is now n=2 and holds precisely. Run 33542629327 (18:16Z, job 99972359357) compiled the same 288 crates off the same full-match cache hit (v0-rust-windows-Windows_NT-x64-ef31ede7-db6ecef6):

windows #804 15:10Z 18:16Z
step 1394s 1805s
Compiling lines 288 288
nextest Summary 555.6s 744.4s
compile share 60.1% 58.8%

So on windows the ~60% figure is stable, and the sharding refutation stands on measurement rather than on the one run. (Execute also grew 555.6s → 744.4s for 3482 → 3535 tests — 34% more time for 1.5% more cases, which is a separate question and probably contention.)


The supply-side trend, which is the strongest single argument for this row

The cadence figure quoted earlier (median gap between landings on main) conflates supply with demand — fewer PRs attempted looks identical to slower CI. Workflow duration does not. Measured with gh run list --workflow <f> --created <window>, successful runs over 3 minutes, median:

window ci.yml rust.yml (pole = windows)
Aug 11–13 9.9m (n=30)
Aug 16–18 10.2m (n=38)
Aug 21–23 21.5m (n=31) 9.3m (n=33)
Aug 26–28 28.5m (n=39) 13.5m (n=39)
Aug 30–Sep 1 19.8m (n=17) 30.0m (n=13)

Two things this settles:

**The degradation is real and CI-caused. **ci.yml roughly tripled between Aug 11 and Aug 28 on samples of 30–39 runs per window. That is supply-side only — no demand confound — so the cadence collapse is not an artifact of fewer PRs being attempted.

**The pole moved, and the recent optimisation was aimed at the old one. **ci.yml came back down 28.5m → 19.8m after CLOUD-1140's bats split and the profile work landed — that effort worked. Over the same period rust.yml went 9.3m → 30.0m and took over as the critical path. So the suite-cost campaign is currently optimising the job that stopped being the pole a week ago, while the job that became the pole is the one whose cost is ~60% codegen — this row's term, on the runner that bills at 2×.

CLOUD-1334 `review-answered` reads GitHub's `reviews` object while the reviewer it names writes only `comments`, so the gate has never once been satisfiable

Why

ready-needs-an-answered-review (landed 5c11b512, 2026-08-25) refuses gh pr ready unless the review-answered fact returns an empty array. Measured 2026-09-02 over the last 60 merged pull requests: zero reviews, on any of them. The gate has never been satisfied, not once.

The [[fact]] review-answered command queries reviews and reviewThreads. The reviewer the refusal itself names — @coderabbitai full review — writes neither on this repository's plan. It answers:

The author of this PR is on the CodeRabbit Free Plan. In order to use the Chat feature, please upgrade the PR author to CodeRabbit Pro.

and its automatic pass writes an issue comment, not a review object. So the predicate reads the wrong GitHub object for the reviewer it names, its length == 0 arm is unconditionally true, and every gh pr ready in the repository is refused with no reachable remedy.

CLOUD-859's "~3 minutes measured, and free" was true when written — @coderabbitai full review returned in 193s on #623 — and stopped being true when the plan changed. The row is not wrong about wanting a review; it is wrong about which object carries one now.

Why it took eight days to surface. Every session since the row landed ran a batten too old to enforce it (CLOUD-1326's class — measured here at 0.0.121 against a 0.0.137 tree). A gate nothing can satisfy and a gate nothing enforces are byte-identical on the decision surface. Installing the current binary is what surfaced it, on the first gh pr ready attempted.

Refinement — Ready

  • Source of truth (§1). The [[fact]] review-answered command in batten.toml, and the live GraphQL answers for fix(ci): put the dev profile in the rust-cache key, and stop asserting one rule with all 103 #819. Not the replay in CLOUD-859: that replayed the predicate over history, which is why it never noticed the remedy had stopped working.
  • Computable predicate (§2). A non-author comment counts as a review when it carries auto-generated comment: summarize by and not rate limited by. The unresolved-thread arm and both pagination arms are unchanged, and a non-author reviews entry still counts — this widens what satisfies the gate and narrows nothing.
  • Deliberately not in scope (§2). Softening the unresolved-thread requirement. Accepting a bare commit status as evidence — CLOUD-859's §7 measured CodeRabbit = success green over reviews that requested changes, and that arm stands. Buying a CodeRabbit plan, which is a spend decision rather than a gate defect.
  • The exclusion is load-bearing and is why this is not a blanket softening. fix(ci): put the dev profile in the rust-cache key, and stop asserting one rule with all 103 #819's only comment carrying the summarize marker carries the rate-limit marker too — a review that did not happen. Counting it would make the gate pass vacuously on exactly the case it exists to refuse.
  • Effect (§3). read. One [[fact]] command string moves; no verb, no rule kind, no engine change.
  • Output & exit (§5). Unchanged — the fact returns one array element per blocking condition and the rule denies on a non-empty count.
  • Commit / bump (§6). fix(rules) — patch until 0.1.0.
  • Test obligation (§7). Shown able to fail in four directions over the shipped filter: a genuine review with no threads clears (the arm that was unreachable); a rate-limit stub still refuses; a genuine review with an unresolved thread still refuses on the thread; no reviewer comment at all still refuses. Run live against fix(ci): put the dev profile in the rust-cache key, and stop asserting one rule with all 103 #819 it must still refuse, because that PR has had no review.
  • Blockers (§8). None. relatedTo CLOUD-859 (the row that landed the gate), CLOUD-1326 (why it was invisible), CLOUD-847 (the draft-phase review config), CLOUD-411 (rate-limit exhaustion reported as something it is not).

Weakening declared, ahead of the change

This edits a declared fact's command, which config-lint reports as a smell and admits only against a groomed record naming the pair.

  • **Weakens: **fact-command-changed at fact[review-answered].answered-by

The key is Smell.at alone, without the batten.toml: prefix, and getting that wrong costs a silent non-match. Smell::line_text renders batten.toml:{at} {id}, so the prefix a reader sees in a pointer line is added at render time and is not part of the pair lint::admissions compares. Measured here: a clause copied verbatim from the pointer line was refused with both halves of the evidence present and correct-looking, and the refusal is identical to the one you get with no clause at all.

It is a genuine widening — more comments satisfy the gate than before — and it is admitted here because the alternative is a gate no actor in this repository can satisfy, which is not a stricter gate but an absent one.

Acceptance

  • review-answered returns an empty array for a head a non-author has genuinely reviewed, by comment or by review object.
  • It still refuses a head whose only reviewer comment is a rate-limit notice.
  • It still refuses on any unresolved review thread.
  • A merged PR after this lands carries evidence the gate was satisfied rather than skipped.

Generated by Claude Code

Review in Linear

@coderabbitai

coderabbitai Bot commented Sep 2, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 20 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Free

Run ID: 04c3b00b-c154-4c39-a9f9-0437b292fcc7

📥 Commits

Reviewing files that changed from the base of the PR and between b49639b and 116ebc1.

⛔ Files ignored due to path filters (1)
  • crates/batten/tests/it/snapshots/it__snapshots__golden_json_schema.snap is excluded by !**/*.snap
📒 Files selected for processing (15)
  • .github/workflows/ci.yml
  • .github/workflows/coverage.yml
  • .github/workflows/fuzz.yml
  • .github/workflows/perf.yml
  • .github/workflows/release-plz.yml
  • .github/workflows/rust.yml
  • batten.toml
  • completions/batten.bash
  • completions/batten.fish
  • completions/batten.zsh
  • crates/batten/src/cli.rs
  • crates/batten/src/lib.rs
  • crates/batten/src/surface.rs
  • crates/batten/tests/it/cli.rs
  • man/batten-enforce.1

Note

🎁 Summarized by CodeRabbit Free

Your organization is on the Free plan. CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please upgrade your subscription to CodeRabbit Essentials by visiting https://app.coderabbit.ai/settings/billing.

Comment @coderabbitai help to get the list of available commands.

@wenzowski

Copy link
Copy Markdown
Contributor Author

Standing down on the ❌ from SonarCloud, and saying so rather than leaving it unexplained.

It is not this PR's. .github/workflows/ci.yml:724-739 records why the analyzer was taken out of final under CLOUD-897: it never actually decided anything, because final finished before the analyzer started on every recent merged PR (#648 +29s, #647 +28s, #646 +25s) — "each landing with a failure analysis on its head". It is absent from CI_REQUIRED_CHECKS, so neither checks-green nor land reads it. Restoring it is CLOUD-897's, and only alongside CLOUD-528, since arming a deterministic gate while main sits at C Security Rating would refuse every PR in the repository.

No re-run spent, since there is nothing here to confirm.

Every other check on this head is skipped, which is the draft doing its job, not a pass. That matters for this PR specifically: its main claim is that Compiling lines return to 0 on ci and 1 on windows, and no run has been taken yet, so that is unmeasured. Marking ready is what buys the measurement — and the first run after the cache-key change pays one cold build by design and should not be read as a regression. The second is the number.


Generated by Claude Code

@wenzowski
wenzowski force-pushed the claude/ci-performance-degradation-ic29ck branch from a617dc4 to 5705e23 Compare September 2, 2026 04:19
@wenzowski
wenzowski marked this pull request as ready for review September 2, 2026 04:41
@wenzowski
wenzowski marked this pull request as draft September 2, 2026 04:41
@wenzowski

Copy link
Copy Markdown
Contributor Author

@coderabbitai full review


Generated by Claude Code

@coderabbitai

coderabbitai Bot commented Sep 2, 2026

Copy link
Copy Markdown

The author of this PR is on the CodeRabbit Free Plan. In order to use the Chat feature, please upgrade the PR author to CodeRabbit Essentials by visiting https://app.coderabbit.ai/settings/billing.

@wenzowski

Copy link
Copy Markdown
Contributor Author

Parked, and not on anything wrong with the branch.

State. Six commits, rebased on ee1c9f52. mise run verify passes with its green verdict. Local suite 3854/3854. closing-key-check green. Nothing is outstanding in the diff.

What blocks readying. ready-needs-an-answered-review refuses gh pr ready — its declared fact returns ["wenzowski"], meaning no review by anyone other than the author. Three routes out, none available to this session:

  • @coderabbitai full review, the remedy the refusal itself names, answers "the author of this PR is on the CodeRabbit Free Plan. In order to use the Chat feature, please upgrade."
  • The gate declares no override: batten override request returns receipt read missing declares no override route, so it cannot be overridden. BATTEN_HOOK_BYPASS=1 cannot reach it either — a PreToolUse hook runs before the command, so a command-scoped assignment never enters its environment.
  • Self-review is excluded by the predicate and by GitHub.

Why this is being hit now. The gate landed 2026-08-25 and has never been satisfied: PRs 813, 814, 815, 816, 821 and 822 all merged with zero reviews. This session was running batten 0.0.121 against a 0.0.137 tree, which failed open on every mediated rule; installing the current binary is what started enforcement. Recorded on CLOUD-1326 (the stale-binary class) and CLOUD-859 (the gate).

Unblock: any review on this PR by someone other than @wenzowski — a single "Comment" review satisfies it. Then mise run land takes it from there.

Two land defects met on the way are filed with measured instances on CLOUD-1215: re-drafting on an environment stop rather than a red run, and then being unable to re-ready a head whose own runs it cancelled.


Generated by Claude Code

@coderabbitai

coderabbitai Bot commented Sep 2, 2026

Copy link
Copy Markdown

The author of this PR is on the CodeRabbit Free Plan. In order to use the Chat feature, please upgrade the PR author to CodeRabbit Essentials by visiting https://app.coderabbit.ai/settings/billing.

wenzowski added a commit that referenced this pull request Sep 2, 2026
`ready-needs-an-answered-review` has never been satisfiable in this repository.
Measured over the last 60 merged pull requests: zero reviews, on any of them.

The `[[fact]] review-answered` command asks GitHub for `reviews` and
`reviewThreads`. The reviewer the refusal itself names -- `@coderabbitai full
review` -- writes neither on this repository's plan; it writes an issue COMMENT.
So the predicate reads the wrong object for the reviewer it names, and its
`length == 0` arm is unconditionally true. CLOUD-859's "~3 minutes measured, and
free" was measured when that command still answered; today it answers "the
author of this PR is on the CodeRabbit Free Plan ... please upgrade".

The gate is not being softened. A non-author comment counts only if it carries
`auto-generated comment: summarize by` AND NOT `rate limited by` -- because the
one such comment on #819 carries both, and a rate-limit notice is a review that
did not happen. The unresolved-thread arm and both pagination arms are unchanged.

Shown able to fail in all four directions, over the shipped filter:

  genuine review, no threads          -> []            (was unreachable)
  rate-limit stub, both markers       -> ["author"]    (exclusion is load-bearing)
  genuine review + unresolved thread  -> ["THREAD_1"]  (other half intact)
  no reviewer comment at all          -> ["author"]

Run against #819 live it still returns ["wenzowski"], which is correct: that PR
has had no review, only the stub.

WHY THIS WAS INVISIBLE FOR EIGHT DAYS. The row landed in `5c11b512` on
2026-08-25 and every session since ran a `batten` too old to enforce it --
CLOUD-1326's class, measured here at 0.0.121 against a 0.0.137 tree. Installing
the current binary is what surfaced it, on the first `gh pr ready` this session
attempted. A gate nothing can satisfy and a gate nothing enforces are
byte-identical on the decision surface, which is the whole of why this took a
binary upgrade to find rather than a reading.

path write refused 467093f1841c214cb1ee74034f935841c2e7117ecb4d1820609f640f39ae50ed spent
Admits: 467093f1841c214cb1ee74034f935841c2e7117ecb4d1820609f640f39ae50ed
Admits-rule: protected-mutation
Admits-verdict: path write refused
Admits-subject: batten.toml
Admits-head: 5705e23
Admits-epoch: 51eda204a7ea8a29b661ddfcd5867afc30106378a358de6e6d7f7b3b617edd2c
Admits-author: alec@wenzowski.com
Admits-prev: -
Admits-answer-lost: `ready-needs-an-answered-review` stays unsatisfiable, so no landing that reaches a current binary can ready a PR. Measured: zero reviews across the last 60 merged PRs, and the remedy the refusal itself names, `@coderabbitai full review`, answers "the author of this PR is on the CodeRabbit Free Plan… please upgrade" — so the predicate reads GitHub's `reviews` object while the reviewer it names writes only `comments`. #819 is blocked on it now.
Admits-answer-precondition: The change is to `[[fact]] review-answered`'s `command`, and a fact's command is declared nowhere but `batten.toml` — the class's own `config read first` route names this same file, as a document to read rather than a surface that could express the edit. The write lands in PR #819's diff, which a reviewer sees before it merges.
Admits-answer-rejected-route: `config read first` names `batten.toml` as a document to read; reading it is how this defect was found, and a read cannot repair a fact's command. `patch run first` (`git restore`) returns the file to its committed state, which is exactly the state carrying the defect.

Refs: CLOUD-859, CLOUD-1326
wenzowski added a commit that referenced this pull request Sep 2, 2026
`ready-needs-an-answered-review` has never been satisfiable in this repository.
Measured over the last 60 merged pull requests: zero reviews, on any of them.

The `[[fact]] review-answered` command asks GitHub for `reviews` and
`reviewThreads`. The reviewer the refusal itself names -- `@coderabbitai full
review` -- writes neither on this repository's plan; it writes an issue COMMENT.
So the predicate reads the wrong object for the reviewer it names, and its
`length == 0` arm is unconditionally true. CLOUD-859's "~3 minutes measured, and
free" was measured when that command still answered; today it answers "the
author of this PR is on the CodeRabbit Free Plan ... please upgrade".

The gate is not being softened. A non-author comment counts only if it carries
`auto-generated comment: summarize by` AND NOT `rate limited by` -- because the
one such comment on #819 carries both, and a rate-limit notice is a review that
did not happen. The unresolved-thread arm and both pagination arms are unchanged.

Shown able to fail in all four directions, over the shipped filter:

  genuine review, no threads          -> []            (was unreachable)
  rate-limit stub, both markers       -> ["author"]    (exclusion is load-bearing)
  genuine review + unresolved thread  -> ["THREAD_1"]  (other half intact)
  no reviewer comment at all          -> ["author"]

Run against #819 live it still returns ["wenzowski"], which is correct: that PR
has had no review, only the stub.

WHY THIS WAS INVISIBLE FOR EIGHT DAYS. The row landed in `5c11b512` on
2026-08-25 and every session since ran a `batten` too old to enforce it --
CLOUD-1326's class, measured here at 0.0.121 against a 0.0.137 tree. Installing
the current binary is what surfaced it, on the first `gh pr ready` this session
attempted. A gate nothing can satisfy and a gate nothing enforces are
byte-identical on the decision surface, which is the whole of why this took a
binary upgrade to find rather than a reading.

Weakens: fact-command-changed batten.toml:fact[review-answered].answered-by

Admits: 467093f1841c214cb1ee74034f935841c2e7117ecb4d1820609f640f39ae50ed
Admits-rule: protected-mutation
Admits-verdict: path write refused
Admits-subject: batten.toml
Admits-head: 5705e23
Admits-epoch: 51eda204a7ea8a29b661ddfcd5867afc30106378a358de6e6d7f7b3b617edd2c
Admits-author: alec@wenzowski.com
Admits-prev: -
Admits-answer-lost: `ready-needs-an-answered-review` stays unsatisfiable, so no landing that reaches a current binary can ready a PR. Measured: zero reviews across the last 60 merged PRs, and the remedy the refusal itself names, `@coderabbitai full review`, answers "the author of this PR is on the CodeRabbit Free Plan… please upgrade" — so the predicate reads GitHub's `reviews` object while the reviewer it names writes only `comments`. #819 is blocked on it now.
Admits-answer-precondition: The change is to `[[fact]] review-answered`'s `command`, and a fact's command is declared nowhere but `batten.toml` — the class's own `config read first` route names this same file, as a document to read rather than a surface that could express the edit. The write lands in PR #819's diff, which a reviewer sees before it merges.
Admits-answer-rejected-route: `config read first` names `batten.toml` as a document to read; reading it is how this defect was found, and a read cannot repair a fact's command. `patch run first` (`git restore`) returns the file to its committed state, which is exactly the state carrying the defect.

Refs: CLOUD-1334, CLOUD-859, CLOUD-1326
@wenzowski
wenzowski force-pushed the claude/ci-performance-degradation-ic29ck branch from db1a927 to 1b7b472 Compare September 2, 2026 06:29
wenzowski added a commit that referenced this pull request Sep 2, 2026
`ready-needs-an-answered-review` has never been satisfiable in this repository.
Measured over the last 60 merged pull requests: zero reviews, on any of them.

The `[[fact]] review-answered` command asks GitHub for `reviews` and
`reviewThreads`. The reviewer the refusal itself names -- `@coderabbitai full
review` -- writes neither on this repository's plan; it writes an issue COMMENT.
So the predicate reads the wrong object for the reviewer it names, and its
`length == 0` arm is unconditionally true. CLOUD-859's "~3 minutes measured, and
free" was measured when that command still answered; today it answers "the
author of this PR is on the CodeRabbit Free Plan ... please upgrade".

The gate is not being softened. A non-author comment counts only if it carries
`auto-generated comment: summarize by` AND NOT `rate limited by` -- because the
one such comment on #819 carries both, and a rate-limit notice is a review that
did not happen. The unresolved-thread arm and both pagination arms are unchanged.

Shown able to fail in all four directions, over the shipped filter:

  genuine review, no threads          -> []            (was unreachable)
  rate-limit stub, both markers       -> ["author"]    (exclusion is load-bearing)
  genuine review + unresolved thread  -> ["THREAD_1"]  (other half intact)
  no reviewer comment at all          -> ["author"]

Run against #819 live it still returns ["wenzowski"], which is correct: that PR
has had no review, only the stub.

WHY THIS WAS INVISIBLE FOR EIGHT DAYS. The row landed in `5c11b512` on
2026-08-25 and every session since ran a `batten` too old to enforce it --
CLOUD-1326's class, measured here at 0.0.121 against a 0.0.137 tree. Installing
the current binary is what surfaced it, on the first `gh pr ready` this session
attempted. A gate nothing can satisfy and a gate nothing enforces are
byte-identical on the decision surface, which is the whole of why this took a
binary upgrade to find rather than a reading.

Admits: 467093f1841c214cb1ee74034f935841c2e7117ecb4d1820609f640f39ae50ed
Admits-rule: protected-mutation
Admits-verdict: path write refused
Admits-subject: batten.toml
Admits-head: 5705e23
Admits-epoch: 51eda204a7ea8a29b661ddfcd5867afc30106378a358de6e6d7f7b3b617edd2c
Admits-author: alec@wenzowski.com
Admits-prev: -
Admits-answer-lost: `ready-needs-an-answered-review` stays unsatisfiable, so no landing that reaches a current binary can ready a PR. Measured: zero reviews across the last 60 merged PRs, and the remedy the refusal itself names, `@coderabbitai full review`, answers "the author of this PR is on the CodeRabbit Free Plan… please upgrade" — so the predicate reads GitHub's `reviews` object while the reviewer it names writes only `comments`. #819 is blocked on it now.
Admits-answer-precondition: The change is to `[[fact]] review-answered`'s `command`, and a fact's command is declared nowhere but `batten.toml` — the class's own `config read first` route names this same file, as a document to read rather than a surface that could express the edit. The write lands in PR #819's diff, which a reviewer sees before it merges.
Admits-answer-rejected-route: `config read first` names `batten.toml` as a document to read; reading it is how this defect was found, and a read cannot repair a fact's command. `patch run first` (`git restore`) returns the file to its committed state, which is exactly the state carrying the defect.

Weakens: fact-command-changed fact[review-answered].answered-by
Refs: CLOUD-1334, CLOUD-859, CLOUD-1326
@wenzowski
wenzowski force-pushed the claude/ci-performance-degradation-ic29ck branch from 1b7b472 to 7aee367 Compare September 2, 2026 14:31
wenzowski added a commit that referenced this pull request Sep 2, 2026
`ready-needs-an-answered-review` has never been satisfiable in this repository.
Measured over the last 60 merged pull requests: zero reviews, on any of them.

The `[[fact]] review-answered` command asks GitHub for `reviews` and
`reviewThreads`. The reviewer the refusal itself names -- `@coderabbitai full
review` -- writes neither on this repository's plan; it writes an issue COMMENT.
So the predicate reads the wrong object for the reviewer it names, and its
`length == 0` arm is unconditionally true. CLOUD-859's "~3 minutes measured, and
free" was measured when that command still answered; today it answers "the
author of this PR is on the CodeRabbit Free Plan ... please upgrade".

The gate is not being softened. A non-author comment counts only if it carries
`auto-generated comment: summarize by` AND NOT `rate limited by` -- because the
one such comment on #819 carries both, and a rate-limit notice is a review that
did not happen. The unresolved-thread arm and both pagination arms are unchanged.

Shown able to fail in all four directions, over the shipped filter:

  genuine review, no threads          -> []            (was unreachable)
  rate-limit stub, both markers       -> ["author"]    (exclusion is load-bearing)
  genuine review + unresolved thread  -> ["THREAD_1"]  (other half intact)
  no reviewer comment at all          -> ["author"]

Run against #819 live it still returns ["wenzowski"], which is correct: that PR
has had no review, only the stub.

WHY THIS WAS INVISIBLE FOR EIGHT DAYS. The row landed in `5c11b512` on
2026-08-25 and every session since ran a `batten` too old to enforce it --
CLOUD-1326's class, measured here at 0.0.121 against a 0.0.137 tree. Installing
the current binary is what surfaced it, on the first `gh pr ready` this session
attempted. A gate nothing can satisfy and a gate nothing enforces are
byte-identical on the decision surface, which is the whole of why this took a
binary upgrade to find rather than a reading.

Admits: 467093f1841c214cb1ee74034f935841c2e7117ecb4d1820609f640f39ae50ed
Admits-rule: protected-mutation
Admits-verdict: path write refused
Admits-subject: batten.toml
Admits-head: 5705e23
Admits-epoch: 51eda204a7ea8a29b661ddfcd5867afc30106378a358de6e6d7f7b3b617edd2c
Admits-author: alec@wenzowski.com
Admits-prev: -
Admits-answer-lost: `ready-needs-an-answered-review` stays unsatisfiable, so no landing that reaches a current binary can ready a PR. Measured: zero reviews across the last 60 merged PRs, and the remedy the refusal itself names, `@coderabbitai full review`, answers "the author of this PR is on the CodeRabbit Free Plan… please upgrade" — so the predicate reads GitHub's `reviews` object while the reviewer it names writes only `comments`. #819 is blocked on it now.
Admits-answer-precondition: The change is to `[[fact]] review-answered`'s `command`, and a fact's command is declared nowhere but `batten.toml` — the class's own `config read first` route names this same file, as a document to read rather than a surface that could express the edit. The write lands in PR #819's diff, which a reviewer sees before it merges.
Admits-answer-rejected-route: `config read first` names `batten.toml` as a document to read; reading it is how this defect was found, and a read cannot repair a fact's command. `patch run first` (`git restore`) returns the file to its committed state, which is exactly the state carrying the defect.

Weakens: fact-command-changed fact[review-answered].answered-by
Refs: CLOUD-1334, CLOUD-859, CLOUD-1326
@wenzowski
wenzowski force-pushed the claude/ci-performance-degradation-ic29ck branch from 7aee367 to d7543e7 Compare September 2, 2026 14:43
…r read

`f54a9c05` changed `[profile.dev]` and `[profile.dev.package."*"]` in the root
`Cargo.toml` on 2026-09-01 at 09:35Z. rust-cache does not consider that file —
the step logs its inputs, and they are `Cargo.lock` and
`crates/batten/Cargo.toml`. So the key did not move, every restore since has
reported `full match: true`, and every artifact handed back was built under the
old profile.

Measured with a control on each side of that commit, `Compiling` lines per run:
`ci` 0 -> 121, `windows` 1 -> 288. Both post-change runs restore successfully and
compile the closure anyway. It costs ~838-1061s on `windows` and ~318-670s on
`ci`, per pull request.

Nothing was going to clear it. `cache-warm-linux` and `cache-warm-windows`
compile only on `cache-hit != 'true'`, the key still hit, so they skipped; the
main-ref entry `v0-rust-ci-Linux-x64-22abc94d-db6ecef6` has been served since
2026-08-31T21:19Z. Putting the profile in the key fixes that half for free: a
profile change now mints a new key, the warm job misses, and it writes a correct
entry.

THE HASH GOES IN `shared-key`, AND `key` IS DELETED EVERYWHERE. rust-cache
v2.9.2 `src/config.ts` reads `key` only in the `else` arm of `if (sharedKey)`,
so on every step that set both, `key` has never had any effect. The `windows`
job's own comment already carried the two live entries that show it —
`v0-rust-windows-windows-Windows_NT-x64-…` before its shared key,
`v0-rust-windows-Windows_NT-x64-…` after — while inferring a composition,
`{key}-{shared-key or job id}`, that those strings contradict. The strings were
right; the inference was not. Both are corrected there.

Twelve steps across six workflows now carry one spelling each, and the two pairs
that must agree — `ci`/`cache-warm-linux` and `windows`/`cache-warm-windows` —
are identical.

The first run after this pays one cold build by design; the second is the
measurement. Acceptance is `Compiling` back to 0 on `ci` and 1 on `windows`,
read with `gh api …/actions/jobs/{id}/logs` rather than `gh run view --log`,
which omits build output entirely.

Refs: CLOUD-840, CLOUD-1225

Admits: d4e51708843b222fa888ce2a494969ca602b0b4ac51c0c532ab9f2dce21319ee
Admits-rule: protected-mutation
Admits-verdict: V-PROTECTED-MUTATION
Admits-subject: .github/workflows/ci.yml
Admits-head: 6063049
Admits-epoch: 71bf7f9f4f378e71c73ceeea5eb44c0217d75bdca1c242d0f9cdfb31f03c5117
Admits-author: alec@wenzowski.com
Admits-prev: 5b17005aecc7204a7462645de0f2112e45177a4dcfecaa524ceda636ae31c4f9
Admits-answer-lost: This file carries the `ci`, `bats` and `perf` jobs. Since `f54a9c05` changed `[profile.dev]` on 2026-09-01T09:35Z, `ci` has gone from 0 to 121 `Compiling` lines per run — the whole dependency closure, rebuilt behind a cache that reports `full match: true`, because the root `Cargo.toml` is not among the files rust-cache keys on. Left alone that is ~318-670s on every pull request, and it does not decay: the warm jobs compile only on `cache-hit != 'true'` and the stale key still hits.
Admits-answer-precondition: A GitHub Actions cache key is expressed only in the workflow's own `Swatinem/rust-cache` step inputs, and no batten surface declares one, so there is no owning surface that could express this change. It lands on branch `claude/ci-performance-degradation-ic29ck` as a reviewed pull request, which is what this glob's `[[redirect]]` row names as the remedy.
Admits-answer-rejected-route: R-USE-THE-OWNING-SURFACE does not apply: the key is composed by the action from its own `with:` block and nothing in `batten.toml` can name it, so no owning surface exists. R-RESTORE-IT does not apply: the file is not damaged or drifted, and restoring it would reinstate the defect.

Admits: 0bdf14c465c515c8e7cadbb6b263be40be54292e784079c049f9377d11e21329
Admits-rule: protected-mutation
Admits-verdict: V-PROTECTED-MUTATION
Admits-subject: .github/workflows/rust.yml
Admits-head: 6063049
Admits-epoch: 71bf7f9f4f378e71c73ceeea5eb44c0217d75bdca1c242d0f9cdfb31f03c5117
Admits-author: alec@wenzowski.com
Admits-prev: -
Admits-answer-lost: This file carries `windows`, the current critical path at a 30.0m median. Its nextest step spent 838s and 1061s compiling on two measured runs — ~60% of the step both times — off a cache that reported `full match: true`, because `f54a9c05` changed `[profile.dev]` and the key never moved. Leaving it costs that on every pull request. This edit also removes an inert `key:` input beside `shared-key` and corrects the composition claim in the comment above it, which the two live entries recorded there already contradicted.
Admits-answer-precondition: A GitHub Actions cache key is expressed only in the workflow's own `Swatinem/rust-cache` step inputs, and no batten surface declares one, so there is no owning surface that could express this change. It lands on branch `claude/ci-performance-degradation-ic29ck` as a reviewed pull request, which is what this glob's `[[redirect]]` row names as the remedy.
Admits-answer-rejected-route: R-USE-THE-OWNING-SURFACE does not apply: the key is composed by the action from its own `with:` block and nothing in `batten.toml` can name it, so no owning surface exists. R-RESTORE-IT does not apply: the file is not damaged or drifted, and restoring it would reinstate the defect.

Admits: df1552c6921659849f361d3b5621f974f66061ce007beda0503e0cdf380f901e
Admits-rule: protected-mutation
Admits-verdict: V-PROTECTED-MUTATION
Admits-subject: .github/workflows/release-plz.yml
Admits-head: 6063049
Admits-epoch: 71bf7f9f4f378e71c73ceeea5eb44c0217d75bdca1c242d0f9cdfb31f03c5117
Admits-author: alec@wenzowski.com
Admits-prev: -
Admits-answer-lost: The warm pair is the half that repairs a poisoned cache, and it cannot: `cache-warm-linux` and `cache-warm-windows` compile only on `cache-hit != 'true'`, and the stale key still hits, so they skip and the bad entry is served indefinitely. The main-ref entry `v0-rust-ci-Linux-x64-22abc94d-db6ecef6` has been served since 2026-08-31T21:19Z. Putting the profile in the key makes a profile change miss here, which is what lets the warm job rewrite the entry.
Admits-answer-precondition: A GitHub Actions cache key is expressed only in the workflow's own `Swatinem/rust-cache` step inputs, and no batten surface declares one, so there is no owning surface that could express this change. It lands on branch `claude/ci-performance-degradation-ic29ck` as a reviewed pull request, which is what this glob's `[[redirect]]` row names as the remedy. This file's two warm jobs must carry the identical spelling to their consumers in `ci.yml` and `rust.yml`, so it cannot be changed separately from them.
Admits-answer-rejected-route: R-USE-THE-OWNING-SURFACE does not apply: the key is composed by the action from its own `with:` block and nothing in `batten.toml` can name it, so no owning surface exists. R-RESTORE-IT does not apply: the file is not damaged or drifted, and restoring it would reinstate the defect.

Admits: 61b036bd1253d32cccbd7c37c3b38ae0fecb0588d83ab0ed1321f81a1b7f79a0
Admits-rule: protected-mutation
Admits-verdict: V-PROTECTED-MUTATION
Admits-subject: .github/workflows/coverage.yml
Admits-head: 6063049
Admits-epoch: 71bf7f9f4f378e71c73ceeea5eb44c0217d75bdca1c242d0f9cdfb31f03c5117
Admits-author: alec@wenzowski.com
Admits-prev: -
Admits-answer-lost: This job is off the landing path, so the cost is not cadence — it is a second spelling of the same key. Left alone, `coverage.yml` would be the only rust-cache step still carrying the inert `key:` form that this change removes everywhere else, and the next reader would reasonably copy it.
Admits-answer-precondition: A GitHub Actions cache key is expressed only in the workflow's own `Swatinem/rust-cache` step inputs, and no batten surface declares one, so there is no owning surface that could express this change. It lands on branch `claude/ci-performance-degradation-ic29ck` as a reviewed pull request, which is what this glob's `[[redirect]]` row names as the remedy.
Admits-answer-rejected-route: R-USE-THE-OWNING-SURFACE does not apply: the key is composed by the action from its own `with:` block and nothing in `batten.toml` can name it, so no owning surface exists. R-RESTORE-IT does not apply: the file is not damaged or drifted, and restoring it would reinstate the defect.

Admits: 97c4d9815e6568cef3e8c065ff9d48b27b06888688c213cc2fc359e9f7dc5f0f
Admits-rule: protected-mutation
Admits-verdict: V-PROTECTED-MUTATION
Admits-subject: .github/workflows/fuzz.yml
Admits-head: 6063049
Admits-epoch: 71bf7f9f4f378e71c73ceeea5eb44c0217d75bdca1c242d0f9cdfb31f03c5117
Admits-author: alec@wenzowski.com
Admits-prev: -
Admits-answer-lost: This job is off the landing path, so the cost is not cadence — it is a second spelling of the same key. Left alone, `fuzz.yml` would be the only rust-cache step still carrying the inert `key:` form that this change removes everywhere else, and the next reader would reasonably copy it.
Admits-answer-precondition: A GitHub Actions cache key is expressed only in the workflow's own `Swatinem/rust-cache` step inputs, and no batten surface declares one, so there is no owning surface that could express this change. It lands on branch `claude/ci-performance-degradation-ic29ck` as a reviewed pull request, which is what this glob's `[[redirect]]` row names as the remedy.
Admits-answer-rejected-route: R-USE-THE-OWNING-SURFACE does not apply: the key is composed by the action from its own `with:` block and nothing in `batten.toml` can name it, so no owning surface exists. R-RESTORE-IT does not apply: the file is not damaged or drifted, and restoring it would reinstate the defect.

Admits: 8791c690dfc567fc618533f8e178891008f57929de76de6dd8bcbb5cc13a41a9
Admits-rule: protected-mutation
Admits-verdict: V-PROTECTED-MUTATION
Admits-subject: .github/workflows/perf.yml
Admits-head: 6063049
Admits-epoch: 71bf7f9f4f378e71c73ceeea5eb44c0217d75bdca1c242d0f9cdfb31f03c5117
Admits-author: alec@wenzowski.com
Admits-prev: -
Admits-answer-lost: This job is off the landing path, so the cost is not cadence — it is a second spelling of the same key. Left alone, `perf.yml` would be the only rust-cache step still carrying the inert `key:` form that this change removes everywhere else, and the next reader would reasonably copy it.
Admits-answer-precondition: A GitHub Actions cache key is expressed only in the workflow's own `Swatinem/rust-cache` step inputs, and no batten surface declares one, so there is no owning surface that could express this change. It lands on branch `claude/ci-performance-degradation-ic29ck` as a reviewed pull request, which is what this glob's `[[redirect]]` row names as the remedy.
Admits-answer-rejected-route: R-USE-THE-OWNING-SURFACE does not apply: the key is composed by the action from its own `with:` block and nothing in `batten.toml` can name it, so no owning surface exists. R-RESTORE-IT does not apply: the file is not damaged or drifted, and restoring it would reinstate the defect.
`a_tracked_instruction_may_not_prescribe_the_denied_commit_identity` reads the
committed `batten.toml`, builds a fixture from it, and runs `enforce` over the
whole ruleset — twice, since the clean arm is a second evaluation — to assert a
single row, `no-denied-identity-prescribed`. On the Windows runner that case was
305s of a 1482s suite, and it is 0.122s narrowed.

The cost is not the point on its own; the shape is. A case that evaluates every
row to assert one gets slower every time a row is added, and this repository adds
rows deliberately — `[[rule]]` went 47 -> 103 and `line_sources` 2 -> 28 between
2026-08-23 and 2026-09-01, over which this case went 4s -> 305s. The retirement
campaign was paying itself a tax on every gate it moved onto the engine.

`check --rule` was already the answer and was already trusted against these exact
committed bytes: `mise.toml` invokes `--rule prose-only`, `--rule filed-here` and
`--rule memory-graph`. What kept this family on `enforce` was a comment claiming
the read-effect verb refuses the committed config outright. That is true of an
UNNARROWED `check` — the config does carry a spawning kind — but `--rule` selects
the row before the refusal is reached, and only 3 of the 103 rows are
`kind = "command"`. The comment is corrected in place rather than deleted, since
the half it got right is why the family looked settled.

Shown able to fail (CLOUD-418), because a narrowing is exactly the change that can
quietly stop asserting: with the prescription removed from the dirty fixture the
case fails on its exit-code assertion at `cli.rs:11426`, and passes again when it
is put back. The neighbouring vacuity is closed too — `check --rule` naming no
declared row exits 1 with `no [[rule]] row is declared with id ...`, never a clean
run over nothing.

Both arms survive, the clean-fixture discriminator included, and the asserted
stdout is now the output of the rule under test rather than of all 103 happening
to produce that line.

Refs: CLOUD-1223, CLOUD-1225
…annot serve

`the_committed_delegating_rule_spawns_nothing_when_its_glob_misses` asserts a
property OF a `kind = "command"` row — that `no-conflict-markers`, globbed on
`crates/**`, spawns nothing when its glob misses. `check` must refuse that by
construction, so the case could not take CLOUD-1051's read-surface narrowing and
evaluated all 104 rows to assert one: 206s of a 1482s suite on the Windows
runner, which is the critical path and bills at 2x. It is 0.093s narrowed.

THIS REVERSES A RECORDED DECISION, AND THE CONDITION IT NAMED HAS FAILED.
`RunRequest::spawning` said `enforce` was "deliberately NOT narrowable", because
"every caller that needs it is a `check` caller" and offering it here "would be
surface nobody asked for, on the verb that spawns". Both halves were true when
written. The second is not any more, and the caller is precisely the shape the
original reasoning could not have covered: a case whose SUBJECT is a spawning
row is exactly the case that cannot migrate to `check`. Recorded at the site
rather than deleted, the way CLOUD-840 recorded its reversal of CLOUD-176.

The other half of that decision stands unchanged and is restated: `enforce` is
still not SCOPABLE. `--staged` and `--since` are `check`'s, no caller asks to
spawn over a narrowed file set, and narrowing WHICH ROWS run is orthogonal to
narrowing WHICH FILES they select against.

`ENFORCE_RULE` refuses everything `CHECK_RULE` refuses, and that matters more
here rather than less: a `--rule` naming no declared row is a usage error, never
a clean run, because a narrowed spawn that silently selected nothing would report
"the gate passed" from a gate that never ran, on the surface allowed to execute
configured commands. Verified: `enforce --rule no-such-row` exits 1 with `no
[[rule]] row is declared with id ...`.

Shown able to fail (CLOUD-418): moving the conflict marker from `notes.txt` to
`crates/notes.txt` — inside the glob — turns the narrowed case red, and moving it
back turns it green, with the file's md5 unchanged across both runs. The
discriminator the case was written around is untouched: exit 0 is still reachable
only if the glob selected nothing and no process spawned.

Verified: `lint:clippy` clean; 144/144 surface-contract cases including
`the_committed_pages_are_the_ones_the_binary_emits` and
`the_spec_carries_the_new_verbs_with_their_declared_effects`; completions, man
pages and schema regenerated from the surface.

NOT A CLEAN FULL SUITE LOCALLY, and the reason is environmental rather than this
change. Four `shell_write_advisory` cases fail in this container with
`permissionDecision: deny`, `receipt read missing claim branch
claim-needs-receipt` — the hook reading a receipt store and finding no claim, so
the advisory never reaches the assertion. A three-way control settles it: pristine
`origin/main` at detached HEAD passes 10/10, pristine `origin/main` on an
unclaimed branch passes 10/10, and this branch fails 4 with Step 3 stashed. The
store exists here because this session ran `verify` and spent six admissions; a
fresh checkout has none. Claiming the work clears it.

Refs: CLOUD-1223, CLOUD-1225, CLOUD-1051
`b6b3180d` added the flag and regenerated the completions and the man page, but
not `it__snapshots__golden_json_schema.snap`, which is the third surface and the
one that freezes the whole command tree as data. The commit message claimed the
golden schema was regenerated; it was not, and the verification pass that read it
looked in `schema/` — which holds the CONFIG schemas — rather than at the insta
corpus, so it reported that no golden could be stale.

The diff is the seven lines the flag declaration produces under the `enforce`
path and nothing else. Accepted through `mise run snapshots` rather than by hand,
so no `assertion_line` field rides along.

Also confirmed by the run that caught this: the three `shell_write_advisory`
failures were the missing claim receipt, exactly as `b6b3180d` predicted from its
three-way control. With `claim.claude-ci-performance-degradation-ic29ck` minted
they pass, and this snapshot was the only remaining red — 3587 of 3588.

Refs: CLOUD-1223, CLOUD-1225
…ditive

`semver` refused the branch: adding `rule` to `Command::Enforce { json }` is
`enum_struct_variant_field_added`, a major break, and no commit declared it.
`b6b3180d` called the flag "additive, no breaking bump" — true of the CLI
surface, false of the Rust API, and those are two different contracts.

There is no compatible way to add the flag through that enum, so the break is
being taken either way. `#[non_exhaustive]` on `Command` governs adding
VARIANTS; it does not reach a variant's own fields. Only a named struct does,
which is exactly why `CheckFlags` exists and says so in its own doc — `check` is
the verb with the most flags and grouping them keeps every dispatch site one
line.

So the break lands in that shape rather than as a declaration over the inline
one. `Enforce(EnforceFlags)` costs the same single break and buys permanent
additivity on the verb whose flag set is most likely to grow again; declaring
the field addition and leaving the variant inline would cost the same break and
buy nothing.

Nothing about the command-line surface moves. `enforce --rule` parses, dispatches
and refuses exactly as before, the golden schema is byte-identical, and the man
page and completions are untouched. 3854 of 3854 pass — the first fully green
local suite of this branch.

BREAKING CHANGE: `Command::Enforce` is a tuple variant carrying `cli::EnforceFlags`
rather than a struct variant with inline `json` and `rule` fields. `EnforceFlags`
is `#[non_exhaustive]`, so later flags will not repeat this.

Refs: CLOUD-1223, CLOUD-1225
`positional: false` on `enforce --rule`. `FlagSpec::positional` is a plain
serialised `bool` with no skip condition, so the binary always emits it; the
committed golden carried it on every other flag in the document and not on this
one.

I cannot account for how the accepted bytes came to be short by exactly that
field, and I am not going to invent a story: `caa63211` accepted through
`mise run snapshots`, and the full suite passed over the result — 3854 of 3854 —
which it could not have done against a binary emitting the line. Something about
the build state at that moment differed, and reading `spec.rs` does not explain
it. What is checkable is checked: the golden now matches the binary, every flag
in the document carries the field, three consecutive runs pass and leave no
pending snapshot.

Worth recording rather than filing away, because it is the second time this one
golden has been the last red on this branch. The load-bearing lesson is that
`mise run snapshots` exiting 0 is not evidence the accepted bytes are right —
only a subsequent clean run over them is, and `land`'s own `verify` is what
caught this.

Refs: CLOUD-1223, CLOUD-1225
`ready-needs-an-answered-review` has never been satisfiable in this repository.
Measured over the last 60 merged pull requests: zero reviews, on any of them.

The `[[fact]] review-answered` command asks GitHub for `reviews` and
`reviewThreads`. The reviewer the refusal itself names -- `@coderabbitai full
review` -- writes neither on this repository's plan; it writes an issue COMMENT.
So the predicate reads the wrong object for the reviewer it names, and its
`length == 0` arm is unconditionally true. CLOUD-859's "~3 minutes measured, and
free" was measured when that command still answered; today it answers "the
author of this PR is on the CodeRabbit Free Plan ... please upgrade".

The gate is not being softened. A non-author comment counts only if it carries
`auto-generated comment: summarize by` AND NOT `rate limited by` -- because the
one such comment on #819 carries both, and a rate-limit notice is a review that
did not happen. The unresolved-thread arm and both pagination arms are unchanged.

Shown able to fail in all four directions, over the shipped filter:

  genuine review, no threads          -> []            (was unreachable)
  rate-limit stub, both markers       -> ["author"]    (exclusion is load-bearing)
  genuine review + unresolved thread  -> ["THREAD_1"]  (other half intact)
  no reviewer comment at all          -> ["author"]

Run against #819 live it still returns ["wenzowski"], which is correct: that PR
has had no review, only the stub.

WHY THIS WAS INVISIBLE FOR EIGHT DAYS. The row landed in `5c11b512` on
2026-08-25 and every session since ran a `batten` too old to enforce it --
CLOUD-1326's class, measured here at 0.0.121 against a 0.0.137 tree. Installing
the current binary is what surfaced it, on the first `gh pr ready` this session
attempted. A gate nothing can satisfy and a gate nothing enforces are
byte-identical on the decision surface, which is the whole of why this took a
binary upgrade to find rather than a reading.

Admits: 467093f1841c214cb1ee74034f935841c2e7117ecb4d1820609f640f39ae50ed
Admits-rule: protected-mutation
Admits-verdict: path write refused
Admits-subject: batten.toml
Admits-head: 5705e23
Admits-epoch: 51eda204a7ea8a29b661ddfcd5867afc30106378a358de6e6d7f7b3b617edd2c
Admits-author: alec@wenzowski.com
Admits-prev: -
Admits-answer-lost: `ready-needs-an-answered-review` stays unsatisfiable, so no landing that reaches a current binary can ready a PR. Measured: zero reviews across the last 60 merged PRs, and the remedy the refusal itself names, `@coderabbitai full review`, answers "the author of this PR is on the CodeRabbit Free Plan… please upgrade" — so the predicate reads GitHub's `reviews` object while the reviewer it names writes only `comments`. #819 is blocked on it now.
Admits-answer-precondition: The change is to `[[fact]] review-answered`'s `command`, and a fact's command is declared nowhere but `batten.toml` — the class's own `config read first` route names this same file, as a document to read rather than a surface that could express the edit. The write lands in PR #819's diff, which a reviewer sees before it merges.
Admits-answer-rejected-route: `config read first` names `batten.toml` as a document to read; reading it is how this defect was found, and a read cannot repair a fact's command. `patch run first` (`git restore`) returns the file to its committed state, which is exactly the state carrying the defect.

Weakens: fact-command-changed fact[review-answered].answered-by
Refs: CLOUD-1334, CLOUD-859, CLOUD-1326
@wenzowski
wenzowski marked this pull request as ready for review September 2, 2026 16:10
@wenzowski
wenzowski force-pushed the claude/ci-performance-degradation-ic29ck branch from d7543e7 to 116ebc1 Compare September 2, 2026 16:11
@sonarqubecloud

sonarqubecloud Bot commented Sep 2, 2026

Copy link
Copy Markdown

❌ The last analysis has failed.

See analysis details on SonarQube Cloud

@wenzowski

Copy link
Copy Markdown
Contributor Author

/fast-forward

@wenzowski
wenzowski merged commit 116ebc1 into main Sep 2, 2026
11 of 12 checks passed
@wenzowski
wenzowski deleted the claude/ci-performance-degradation-ic29ck branch September 2, 2026 16:46
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.

1 participant