The fact
compiler/abi.inc states its invariant plainly, and then names its own enforcement:
Backends consult the oracle and never re-derive the convention from
Syms[]. That clause is greppable in review: aSyms[...].IsRef orchain insideir_codegen*.incmeans someone grew a ninth copy.
Measured on today's master:
$ grep -rn "IsRef or" compiler/ir_codegen*.inc | wc -l
0
The declared review clause matches nothing, and has no way to fire. A reviewer who runs exactly the grep the file tells them to run gets a clean result, forever, on any tree — including one where the convention has been re-derived in every backend.
Meanwhile the convention is re-derived longhand, just not with the word or.
ir_codegen_riscv32.inc:1549-1566 decides IR_LEA's parameter-address question — which
is verbatim the question ABIParamSlotHoldsValueAddr exists to answer ("Asked by every
backend when it needs the address of a parameter") — with a hand-written chain over
IsArray, IsRef and TypeKind = tyAnsiString, spelled and ... and not. Same shape at
:2419. The grep cannot see either.
What this is NOT
Not "abi.inc failed." It consolidated eight copies and every backend consults it (1-3 call sites each; none ignores it wholesale). The oracle is doing its job at the sites that call it.
Not "riscv32 is broken." Those sites are heavily commented, each cites a ticket, and
abi.inc's own header says targets need not agree with each other. They may all be
correct today.
The defect is that nothing can tell the difference. The file predicted its own failure mode — "no test would have caught a ninth drifting" — proposed a grep against it, and the grep was calibrated to a spelling rather than to the shape. Drift has since begun and the detector reports clean.
Why it is p45 and not a style ticket
frankwasm paid the real cost in the wasm backend, and the symptom is the expensive kind:
A frozen VALUE parameter is passed as the address of a buffer on every target, and the flag saying so lives on the parameter's SYMBOL, not on the proc's declaration record. Reading the wrong one made
const x: ShortStringcome back with "no wasm value type" — so the callee and every call site went unreachable.
A missing CONVENTION reported as a missing TYPE. The next person follows the message and goes looking for a type mapping that does not exist. Two calls to the oracle fixed it, and by-value copy semantics came free — which is the oracle working exactly as designed, for a backend that had not asked it.
The oracle's stated success metric is that a new pass-by-pointer kind costs one edit. The corollary nobody wrote down is that a backend which does not consult it gets the wrong answer silently, and no gate can see that.
The fix
Not "remove the riscv32 chains" — first make the invariant checkable, then use it to find out whether they are drift or deliberate divergence.
- Replace the grep with something that can fail. The shape to detect is
a type-kind test combined with
IsRef/IsArrayinsideir_codegen*.inc, not the tokenor. A ~20-line script intools/besideforwardlint.pyis the right size; it must produce a non-empty baseline today and be reviewed down to zero, because a new check whose first run is clean has proved nothing. - Audit the sites it finds. For each: does it ask a question the oracle answers? If
yes, call the oracle. If it is a deliberate per-target divergence, it belongs in the
oracle's table where
abi.incsays divergence should be "deliberate and reviewable instead of accidental and invisible." ir_codegen.inchas the most type-combined sites (21) and also consults the oracle most. Expect the highest false-positive rate there; do not let that stall the pass.
ir_codegen_wasm32.inc is not on master (branch only), so its two instances are not in
the counts above. It will arrive with the merge.
Related
Same generator as bug-a-per-cpu-ifdef-chains-in-builtinheap-fail-open and
bug-p-an-unknown-compiler-directive-is-silently-ignored: a check or a chain whose
failure case is unreachable, so its silence carries no information. Third structural
instance this week. A check that cannot fail and a check that is passing are the same
observation, and only one of them is worth anything.
The same disease with a second mechanism — verified 2026-08-28
bug-a-a-string-function-result-in-a-comparison-leaks-on-x86-64 [A, p70] is this ticket's
problem wearing a different failure mode, and the pair is worth reading together because the
fix shape is shared.
Counted directly (grep -n IRNodeOwnsManagedStr compiler/ir_codegen*.inc):
| backend | binop sites asking the ownership predicate |
|---|---|
| aarch64, arm32, riscv32, i386 | 3 each (concat, equality, ordered) |
x86-64 (ir_codegen.inc) |
1 |
Result: if F(x) = 'lit' leaks F's result on the default target, every evaluation, -O0
and up, silent and unbounded — 40 bytes an iteration, measured.
The distinction that makes this pair instructive:
- This ticket: an oracle exists (
abi.inc) and backends re-derive instead of asking. - That ticket: an oracle exists (
IRNodeOwnsManagedStr, one function) and a backend forgets to ask at one of the sites where asking is required.
Both are an obligation across a backend × site matrix with nothing enforcing completeness, and in both cases a grep for the predicate's name returns plenty of hits and tells you nothing — the defect is a hit that is absent, which is the family's signature.
The mirror is already in done/. bug-a-a-string-function-result-in-a-concat-leaks-on- every-cross-target fixed the opposite half — predicate right on x86-64, missing from the
four cross backends — and its own comment observed "this was the FIFTH hand-written copy of
that predicate." It fixed the copies without removing the need for copies, so the other half
of the same fifteen-cell matrix stayed broken and nothing noticed for months.
So neither ticket may be closed by adding more call sites. Closing this family means one of: a completeness check over the matrix, or a shape where the obligation cannot be omitted (the predicate asked once at the shared layer, backends receiving the answer). Six more copies and no note about the sixteenth is the failure this pair documents.
RESOLVED 2026-08-31 — frank-rust
The grep had rotted further than the ticket recorded
The ticket measured grep -rn "IsRef or" compiler/ir_codegen*.inc | wc -l as
0. On 2026-08-31 it is 1, and the one hit is
ir_codegen_wasm32.inc:1736 — a comment quoting this very rule.
That is strictly worse than zero, and it is worth stating because it inverts the ticket's own framing. A reviewer who runs the prescribed grep now gets a non-empty result, opens it, finds prose describing the shape to avoid, and concludes the check ran and the tree is clean. The dead check acquired a false positive that reads as evidence it works.
What replaced it
tools/abi_oracle_lint.py. It asks the shape's question: does one boolean
condition in ir_codegen*.inc combine an ABI-carrying Syms[] field
(IsRef/IsArray) with a type-kind test, for a parameter? Conditions are
followed across line breaks to their then/do, because the real chains wrap —
which is the other half of why a line-oriented grep could not see them.
Eight self-controls, all asserted (--selftest). The ones that earned their
keep:
- the shape in the spelling the old grep could see (
IsRef or), and in the spelling that defeated it (and ... and not, wrapped over two lines); - the real wasm32 comment above — must NOT be reported, which is what proves the comment stripper works on the exact line that fooled the old check;
- a routine-scoped exemption must silence the routine it heads and must not leak into the next one. That control is the one I would have skipped and it is the one guarding the mechanism most able to hide a real hit.
The audit, and why the funnel matters more than the endpoint
| stage | hits | what was removed |
|---|---|---|
| raw shape | 78 | — |
| parameter questions only | 22 | IR_STORE type dispatch; skLocal finalisation |
| after one routine exemption | 6 | EmitParamSpillsForTarget (17 sites) |
| after stating real divergence | 1 | five sites marked abi-divergence: |
The first number was a trap I nearly shipped. 78 is a satisfying baseline
and most of it could never have been an oracle call — local finalisation and
store-lowering type dispatch. A linter whose hits are mostly noise gets muted,
which reaches the same end state as one that cannot fire, just more slowly. The
narrowing came from reading abi.inc's own words — "does a PARAMETER's stack
slot hold the ADDRESS OF THE VALUE" — rather than from tuning until the number
looked right.
EmitParamSpillsForTarget is exempted with a stated reason, not silenced: it
asks the slot width / register class question, which the oracle does not
answer, and it already delegates correctly (ABIParamSlotIsPointer, three call
sites) where the oracle's question does come up. That is 17 of the 22 and the
ticket predicted it — "expect the highest false-positive rate there".
The five marked sites are divergence with a reason, not rubber stamps
Four (riscv32 :1674 :1685, xtensa the same two) turn on InLValueWrite,
and ABIParamSlotHoldsValueAddr(symIdx) takes only a symIdx — the oracle
cannot see lvalue-write context, so the question is genuinely outside the table.
Both chains call the oracle as their final arm. The fifth
(ir_codegen.inc:6341) is nested inside if ... ABIParamSlotHoldsValueAddr then: the oracle has already answered, and the arms only refine how many
dereferences follow.
If the oracle ever grows a write-context parameter, those four collapse into it. That is the real follow-up and it is not free, which is why it is named here rather than done quietly.
The one that is left, and why it stays red
ir_codegen_aarch64.inc:2869 re-derives the question with ... and not IsArray, where the oracle returns True when IsArray is set. Filed as
[[bug-a-aarch64-setlength-on-a-frozen-string-param-diverges-from-the-abi-oracle]]
with the truth table. I did not build a repro and do not claim it is
reachable — the fix is one line either way, and which line depends on that.
The linter is deliberately NOT wired into gate.sh. Wiring it today would
mean either a red gate or marking that site to make it green, and marking a site
I have not resolved to obtain a green baseline is precisely the false zero this
ticket exists to complain about. It is one line in gate.sh the day that ticket
closes.
Scope note
This closes the abi.inc half of the pair. The IRNodeOwnsManagedStr half
recorded at the foot of this ticket is a different oracle with the same
disease and is untouched: the completeness check built here does not detect a
missing call, which is that half's signature. It needs its own instrument, and
the funnel above is the argument for building it around the question rather than
the identifier.
Log
- 2026-08-31 — resolved, commit bd909ff64.
Follow-up, same day: it IS wired, via a baseline — frankwasm's design
I closed this leaving the linter exiting 1 and unwired, reasoning that the only way to a green gate was marking the open aarch64 site, which would manufacture the false zero this ticket exists to complain about. That was a false dichotomy and frankwasm supplied the third option, with the empirical argument attached:
An unwired check's result has to be READ, and reading is the step that fails.
They demonstrated it accidentally while verifying this very tool — ran
abi_oracle_lint.py | tail -15; echo exit=$?, read tail's status, and got
exit 0 from a tool that was exiting 1. Careful, specifically about that
failure mode, four hours after fixing two instances of it. A wired check's
result cannot be misread, because nobody reads it.
The third option: a baseline that cannot outlive its cause. One entry, the aarch64 site, carrying its ticket. Passes on exactly that set, fails on anything new. No suppression, because the entry does not say this is not a finding — it says this is a finding, it is that one, and here is where it is tracked. Only the second can fail later.
The guard that stops it rotting into the thing it replaced, and it is the whole reason the design is acceptable: a baseline entry matching nothing is an ERROR. The day aarch64:2869 is fixed, the linter says "delete this line", rather than passing forever the way the grep did. Keyed on condition TEXT, not line number, because line numbers rot — three site coordinates in this ticket were stale within four days while their reasoning held.
12 asserted controls now (--selftest), 4 of them on the baseline, including
that a stale entry fails and that an entry cannot match a different condition in
the same file. Wired into tools/gate.sh beside forwardlint, whose own
comment is the precedent: "NOTHING INVOKED IT -- so it caught both and told no
one. A trigger nobody is assigned to watch is not a trigger."
And a control of mine went inert while I was proving this. My first stale
test sed-replaced tyAnsiString in the baseline — a token that does not appear
in that condition. The file was unchanged, the run said "clean", and I nearly
recorded that as the stale check passing. It was caught by asserting the
mutation had actually changed the file before trusting the result. A control
needs its own control when the control is a mutation: "I edited it" and "the
edit landed" are two claims, and only the second one is worth anything.
CORRECTION 2026-08-31 — two of my five markers were wrong (frankS, 9c7bdb51a)
I marked five sites abi-divergence:. Three were right. Two were not, and the
way they were wrong is this ticket's own disease committed inside the fix for
it.
The bad pair (riscv32 :1685, xtensa :1670):
else if (Syms[si].Kind = skParam) and Syms[si].IsRef and
not Syms[si].IsArray and (Syms[si].TypeKind <> tyAnsiString) then
No InLValueWrite in it — so the justification I wrote does not apply. And
ABIParamSlotHoldsValueAddr returns True on skParam and IsRef in its first
clause, so this is a strict subset of the catch-all two lines below,
reaching an identical single-word load. A hand-rolled partial copy of the
oracle: exactly what the linter is for.
The linter was right. The marker made it wrong. I wrote one justification, read one arm, and applied it to its neighbour in the same pass because the two were adjacent — a name standing in for the thing it names, which is the defect this ticket exists to fix.
Why this is an argument about markers, not about my care
A { abi-divergence: } marker asserts "this is not a finding" and then can
never fail again. Nothing re-checks it; the only detector is a human re-reading
the arm, which is what the review grep already proved does not happen. So a
wrong marker is permanently wrong and silent — the dead-grep failure mode,
reintroduced one layer up, by the fix.
The baseline added in 78e80830c is the alternative and this pair is the case
for it: an entry says "this IS a finding, it is that one, here is its
ticket", and an entry matching nothing is an error. It cannot outlive its
cause. Prefer a baseline entry to a marker wherever the site is genuinely
open; reserve markers for the cases where the question is provably outside the
oracle's signature, and expect to be checked.
frankS's measurement, which is the part I skipped
They deleted both arms and confirmed byte-identical output — then, because
"byte-identical from a chain nothing reaches is not a measurement", ran the
inverse: disabled the ABIParamSlotHoldsValueAddr arm, kept the deleted one,
and got x=5, a2=0 then a segfault on the Double. That is what makes the
identical result evidence rather than an absence. I annotated on a reading and
measured nothing in either direction.
New coverage came with it (test_cross_var_param_scalar_kinds.pas): var-param
forwarding was covered only by test_esp_varparam.pas, which needs real ESP
hardware, so the hosted path had none — which is how a redundant arm sat in
front of the shared predicate with nothing objecting.