Skip to content

feat: build profile review rules - #21

Merged
gurinderu merged 9 commits into
mainfrom
feat/build-profile-review-rules
Aug 2, 2026
Merged

feat: build profile review rules#21
gurinderu merged 9 commits into
mainfrom
feat/build-profile-review-rules

Conversation

@gurinderu

Copy link
Copy Markdown
Owner

No description provided.

The code under review is not always the code that ships. Two review rules
for the gap:

SAF-007 (HIGH) — arithmetic on an untrusted-input path whose outcome
differs between the dev/test profile (overflow-checks on) and the shipping
release profile (off by default). The profile-gated panic is the floor of
the impact, not the ceiling: "doesn't reproduce in release" is not a
close — a silent wrap that truncates a length or misresolves an index is
worse than the panic because nothing reports it. Report both facets, and
read [profile.release] before assuming the default.

SAF-008 (CRITICAL) — debug_assert! as the only guard on an unsafe
precondition or other load-bearing invariant; it compiles out in release,
leaving the shipped binary unguarded. rust-performance already states the
authoring rule, this is the review rule that catches its violation.

Also wires both into the safety lens brief, the lens table, and a
"what proves what" row: a panic is release-unreachable only if the repro
was re-run under the shipping profile AND the release behaviour is stated.
Most findings rest on a claim that is not visible at the line they cite —
"the dependency rejects this", "reachable from untrusted input", "no caller
guards it". That premise is the one a review most reliably invents, and a
second, smarter reading does not catch it: it reproduces the same
assumption. Only opening the code does. So make it structural rather than
exhortative.

- FINDING_ITEM gains a required `whereChecked`: the file:line the lens
  actually opened, dependency sources included. Lens prompts state that an
  unopened premise is inadmissible — open it or drop the claim.
- VERDICT_SCHEMA gains a required `premiseSupported`: a verifier must open
  the claimed evidence and vote on whether it shows what is claimed.
- An unsupported premise demotes Confirmed to Suspected. It is never filed
  as refuted: unsupported is not disproven, and adversarial-review feeds
  its refuted list forward as "do not re-report", which would bury a
  possibly real defect for the rest of the run over a missing citation.
- Dedup unions whereChecked across merged members, so evidence pinned by a
  member that loses the merge does not cost the group its tier.
- triage-findings takes the symmetric half: premise discipline binds
  *reject* as much as accept. "A caller already validates this", waved
  through without opening the caller, is the same unfounded claim as the
  finding it dismisses — and it discards a real bug silently.

Same treatment in adversarial-review; rubric sections in rust-review and
nix-review; both lens agents. Tests pin the schema contract, that
verifyPrompt asks for every key the verdict schema requires, and that
whereChecked reaches prompts flattened with path identifiers intact.
Three additions to the Rust review rubric.

fp-rules.md — the mirror of rules.md. FP-001..FP-007 are the precedents
under which a finding is dropped; each demands a specific trace, not a
pattern match ("looks guarded" does not fire the invariant-protected rule;
following the invariant to its source and showing it dominates the sink on
every path does). A rejection is a claim and carries the same burden as the
finding it kills, so the verdict cites the ID.

Two of them are downgrades rather than refutations: operator-controlled
input and an operator-only panic surface leave the technical claim intact
and remove only the attacker's access. Killing such a finding outright on
an input that turns out to be network-reachable is the expensive mistake,
and it is invisible once the finding is gone.

KEEP-001..004 are the converse — dismissals that sound decisive and have
repeatedly killed real defects: soundness in a public API no current caller
reaches, a logic bug in safe Rust, a panic unwinding through an unsafe
region, and an unverifiable premise (that is Suspected, not refuted).

INV-005, the mirror walk: on a two-sided contract the finding IS the
asymmetry, no crash required. The error enum indexes the invariants; each
enforcement site is checked against its mirror along four axes
(client/server, send/receive, offered/accepted, one-param/all-params); a
guard present in the last released tag and gone at HEAD is a regression,
which changes severity.

SAF-009 with measured magnitude: "same class as X" is a claim about
mechanism, not severity, and the gap between two exhaustion bugs sharing a
root cause can be orders of magnitude. Rate by attack throughput against a
real-data baseline and attacker-bytes-per-victim-CPU-second, not by the
neighbour's label. Crashes have no throughput curve — rate those by
convention and say it is a judgement call.

Wiring: verifyPrompt now takes the profile so it names the catalog only for
a profile that ships one (nix does not, and a dangling file reference would
send it hunting). Tests cover that gating, that every profile declares
fpRules, and the verifyPrompt extractor is now signature-agnostic.
Closes the last tranche of practices mined from rust-in-peace.

addressing-findings — three checks for when a fix is actually done, each
there because a fix that passed the obvious check still shipped broken:

- Every facet, not the loudest one. A bug with two observable effects (a
  panic and silent corruption, two profiles, two entry points) is not fixed
  until the case for each re-runs green. Saturating arithmetic removes the
  panic while the saturated value still collapses downstream, leaving the
  misresolution alive.
- Your own check, not the fixer's. "Fixed, tests pass" is a claim; the
  test can construct the broken state differently from the real entry
  point. Point a scratch crate at the fix branch and drive the public API.
- Sibling sweep. A fix can close the reported instance and leave an
  identical sibling untouched, invisible to the one case tested. A sibling
  found this way is a NEW finding with its own stable id — folding it into
  the one being closed reports a defect resolved while it still ships.

rust-review — the control/attack differential, the proof form for findings
with no crash (a silently dropped value, a message accepted that should be
rejected): one variable changes between CONTROL and ATTACK, the oracle is
the delta, and asserting both halves makes the reproducer a regression test
a one-sided fix cannot pass. Reach the state through the crate's own test
helpers; a PoC over a mock measures the mock. State what was demonstrated
versus what is inferred.

review.js — reachability is about the ROUTE. Reaching the state by
constructing the object directly (builder, new, fixture) bypasses exactly
the validation the question asks about and proves nothing about
untrusted-input reachability. The trap catches careful reviewers, so the
verifier now checks it explicitly.

docs/LESSONS.md — lessons not derivable from the code, seeded with the two
this adoption produced: a borrowed rule that was inert because craft's
agents have a different shape, and an imported rule set that had to be
re-routed through craft's verdict model before it could be written down.
…silent

Two defects found by running the analyzer over a 60-run store.

The NOISE rank filtered on candidate count alone, never on the refute rate
it claims to rank by. Every lens with >=4 candidates was listed under
"lenses over-refuting" with the advice "tighten this lens's rubric" —
including lenses at refute 0.00. On the store in question 13 of 13 ranked
lenses were listed, the bottom two at 0/9 and 0/5. The advice is backwards
there: tightening a lens whose findings are all confirmed suppresses real
ones. Adds a rate floor, and distinguishes "none are noisy" from "no
telemetry yet" so an empty section is not ambiguous.

loadRecords swallowed unparseable files. The same store holds a 0-byte
record from a write that died mid-flight; the only trace was the run count
not matching the file listing, and the report said nothing. A record that
fails to parse is a run whose telemetry is gone — that is damage, not a
smaller store. It now returns {records, unreadable} and the CLI reports
both the lost records and the pre-telemetry ones it filters out, so the run
total is always explainable.

Neither was pinned by a test: the render test only asserted the NOISE
header exists, and loadRecords had no coverage at all.
Both defects surfaced by reviewing two real run stores (31 and 60 runs).

A dimension row is emitted for every PLANNED lens, so a lens that never
returned recorded as a 0-finding row — identical to one that ran and found
nothing, and its dead runs still counted in the yield denominator. That is
the difference between "redundant, drop it" and "broken, fix it", and it is
exactly the signal the self-improvement loop reads. Runs now carry
ranLenses, dimension rows carry `ran`, and the analyzer excludes dead runs
from the denominator and flags them. Records predating the flag count as
having run rather than being guessed at.

The api-idioms lens brief asks for repeated completeness nits to be rolled
into one finding. The store shows it is not obeyed: 126 confirmed findings
over 21 runs, 100 of them Low/Info, burying the ~2% of findings that
actually drive the verdict. An instruction the model can quietly skip is
not a cap, so rollupPool enforces it: past a threshold, occurrences of a
listed low-value rule fold into one grouped finding that states the count
and names locations. Only completeness nits are listed (API-001/003/004/
005) — never a rule whose instances carry distinct risk — the worst
instances stay individual, nothing is dropped, and the fold is logged.

SEV_RANK moves into the declarations prefix so severity-ranking helpers are
reachable from the test harness.
… gate

Two things the vodopad PR run exposed.

**Gate.** A diff review was reproducing CI locally: it checked port 15432,
ran docker ps, and sat in a cold workspace build for hours. It did that
because CI consumption required the covering check to be marked `required`
— and most repos have no branch protection at all, so `isRequired` is null
on every check and the shortcut was dead code. Required-ness decides
whether RED blocks a merge upstream; it says nothing about whether GREEN is
trustworthy. Green is now consumed regardless, with generous name matching
(`cargo nextest`, `just clippy`, `ci / test (stable)`).

Alongside: the gate never stands up infrastructure — a check needing a
database, container or broker is CI's, recorded unknown with a reason. A
review that never starts is worth far less than one with an unestablished
test signal. Local commands run under `timeout`, and a timeout is an
unknown signal, never a retry. Lint semantics (features, -A allows) come
from the project's own recipe; scope (-p changed packages) and
--message-format=short are ours, since neither changes what a lint says.

**Engine identity.** Records carried `schemaVersion` (the record format)
and `commit` (the *reviewed* project) but nothing identifying craft itself,
so every aggregate silently averaged across rubric versions and no
before/after question could be answered. Adds `craftVersion` (const, kept
in sync with plugin.json by check-workflows — verified to fail on drift)
and `craftCommit` (craft's HEAD, which separates two runs of one release
while the rubric is being edited). Both ride in index.jsonl, since that is
what a filter scans. analyze-runs grows `--version latest|<v>`, and warns
when a store mixes versions instead of quietly averaging them.
…ecords

Verification was 67.6% of a measured run (154 agents, 21MB of transcript on
111 findings) and scaled linearly with finding count, uncapped. Each
finding now goes to the cheapest treatment that cannot change its outcome:

- INDIVIDUAL — Critical/High keep the full adversarial panel, never
  batched, never skipped. Plus any severity carrying a SAF/ERR/CON rule:
  a lens UNDER-calling severity is the one real risk of routing, and those
  are the families where it would hurt.
- BATCHED — Medium can only reach Warning, so one agent judges a group of
  six from one file instead of six agents. A missing index in the batch
  reply falls back to Suspected: a verifier losing a finding must not read
  as a refutation.
- SKIPPED — Low/Info. No verdict on these can move Approve/Warning/Block,
  and Suspected is already defined as "borderline or UNVERIFIED; surfaced,
  never changes the verdict". Spending a skeptic to move a finding from
  Suspected to Suspected buys nothing. Each says on its face that it was
  not verified, and the routing is logged — an unverified item that reads
  like a verified one is the silent cap this codebase refuses elsewhere.

On the measured run's mix that is ~53 fewer agents, ~20% of the whole run,
with the blocking tier untouched. tierFromVotes is now shared by both paths
so the batched one cannot drift from the individual one.

Also: the run record was being RETYPED by a haiku agent from a JSON blob in
its prompt. On a large record it dropped the big arrays — the completed
vodopad review persisted `findings: 111` with `dimensions: []` and no
`verification`, destroying exactly the per-lens telemetry the store exists
for. It is now copied through a quoted heredoc and merged with jq, the
agent verifies the written key set against the input and reports any loss,
and the model scales with payload size. reviewRecord also gained the
craftVersion stamp the earlier commit missed (it builds its own literal).
…ns yield

Measured on a second vodopad run (137 agents, 8.8M tokens, 316 min).

**The escape hatch ate the saving.** Keying it on the SAF/ERR/CON rule
*families* sent 88 of 137 agents — 49% of transcript volume — back into
individual verification, because those families cover unwrap, dropped
errors and every concurrency rule, i.e. most of a Rust review. The hatch
only ever belonged on the SKIPPED tier: batching is still verification, so
a Medium the lens under-called gets a real adversarial judgement either
way. It now fires only for a Low/Info finding citing a specific
block-on-sight rule — that combination is the under-call, and it is rare.

**CI was found by branch name only.** `gh pr checks` resolves the PR from
the current branch, so a review worktree (`pr-1203-review`), a detached
HEAD or a local rename all read as "no PR" while CI is green — a false
negative that costs the entire shortcut and sends the gate into a local
build. The second run hit exactly this. It now falls back to resolving the
PR by HEAD commit via `repos/{owner}/{repo}/commits/{sha}/pulls`.

**Lens rounds are instrumented, not cut.** Lenses are the other half of the
cost (182 min, 31 agents) and every round re-runs every lens over the whole
diff — but whether round 2 earns that cannot be recovered from a finished
run: the gate's seed findings make the pool non-empty from round 1, so no
transcript can be split by round. Records now carry
`lensRounds[{round, agents, returned, newFindings}]`. A maxRounds cut
should be argued from those numbers, not guessed at.
@gurinderu
gurinderu merged commit 95a7ca7 into main Aug 2, 2026
1 check failed
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