Skip to content

Proof layer for the capability checker (55 theorems) + march main resync - #23

Merged
Ch4s3 merged 18 commits into
mainfrom
claude/calculus-proof-capabilities-0a1127
Aug 10, 2026
Merged

Ch4s3 merged 18 commits into
mainfrom
claude/calculus-proof-capabilities-0a1127

Conversation

@Ch4s3

@Ch4s3 Ch4s3 commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Two things, in this order: a proof layer over the capability checker, and a march-main resync that the proof work's CI change happened to expose.

1. A proof layer (P0)

march-lean had zero theorems. Validation was 112 native_decide pins, #eval expectations, and the corpus — all of which check points, not properties. This adds 55 kernel-checked theorems and a CI gate so they stay honest.

The capability lattice is now parameterized over an abstract (name, parent) table (MarchLean/Calculus/Lattice.lean), with the shipping CapLattice API as its specialization to hierarchy — same names, same signatures, definitionally equal. Consequences: the metatheory is proved once for any well-formed table, so adding a capability to march's table re-runs one decide and touches no proof; and transitivity/antisymmetry are structural inductions rather than an 8,000-triple computation over strings.

What is proved:

  • Subsumption is a partial order — subsumesIn_refl, _trans, _antisymm, plus siblings_incomparable and the FFI base case (a name absent from the table subsumes only itself).
  • The fuel bound is adequate — ancestorsIn_fuel_adequate. CapLattice.lean asserted in prose that hierarchy.length is safe "because the table is a finite forest with no cycles." Nothing verified that. Now WellFormedB (no duplicate names, closed parents, acyclic) is a decidable proposition, Calculus/Concrete.lean discharges it for march's 20 entries by decide, and if march ever ships a cycle or a duplicate the build fails there, loudly. Verified by negative control: adding a self-parent row makes the decide fail; reverted.
  • normalize preserves coverage — coveredIn_normalizeIn, the lemma that licenses applying normalize to a needs list at all. It depends on antisymmetry, so it dies exactly when the table gains a cycle. Plus normalizeIn_idem, coveredIn_mono.
  • Verdict algebra — andThen is a monoid with violation absorbing; tierOf is a homomorphism onto Tier.max, i.e. verdict tiers are fold-order-independent even though messages are leftmost-wins by design. DivVerdict.join is a bounded semilattice, so joinAll is permutation-invariant.

No mathlib, no new dependencies — lake-manifest.json stays "packages": [].

CI: sorry is a warning in Lean, not an error, so lake build alone would pass with unproven theorems. Added a source grep that fails the run, plus building the MarchLean library (the workflow only built the executable, so proof files would not have been compiled at all).

Axiom audit — all theorems depend only on propext/Quot.sound, never sorryAx; hierarchy_wellFormed depends on no axioms at all.

2. march main resync

The CI pin was 71 commits stale (7c1d701c), and the march binary on PATH locally was staler still (opam 0.2.0). Repinning to 6867c783 surfaced five verdict-changing divergences, four of them false ACCEPTS. All fixed here.

file direction cause
reject/t149_cap_variant_arg_undeclared false accept capsInSignature scanned only DFn params, so a Cap(X) in a variant constructor argument escaped needs entirely
reject/t151_cap_body_annotation_undeclared false accept Check 1 read signatures only; a lambda parameter annotation inside a body escaped it
reject/t152_root_cap_is_ambient_authority false accept modeled the pre-R2 world where root_cap was an ambient global
accept/t148_cap_narrow_chains false reject modeled the pre-R4a cap_narrow : Cap(IO) -> Cap(a), so no non-root holder could attenuate
reject/t144_cap_derive_json_variant_arg false accept rejection erased by the desugarer — ledgered as a known limitation, verified by inspecting the emitted AST

The R4a fix is the one to read carefully. march retyped cap_narrow to ∀a b. Cap(a) → Cap(b) and moved subsumption into a deferred sweep. Retyping alone here would have traded one false reject for three false accepts: reject/t153 (widen), t154 (siblings) and t155 (deferred widen) were rejecting only as a side effect of the old argument type failing to unify — nothing anywhere consulted the lattice. CapCheck.capNarrowViolation now carries that guarantee explicitly, and the three still reject for the right reason.

That also exposed a second-order gap: Infer was ignoring dfn return annotations, invisible while every builtin's result was pinned by its argument types.

3. Four gaps the corpus cannot catch

Recorded in specs/march-findings.md. These are live divergences against the pinned march with zero corpus witnesses, so a green run is not evidence about any of them:

  1. Path-scoped capabilities (needs IO.FileRead("/etc")) — march emits scopes in a new scopes array; our DNeeds decoder reads only paths. A scope never subsumes unscoped, so a narrow declaration decodes as the broadest possible one: a false accept by construction.
  2. Tagged — march's caps_in_ty now recurses into it; ours still mirrors the arm march deleted.
  3. Check 4 (march#209) — an importer now inherits only the caps of the functions it references. We still use the whole-module rule, making us strictly stricter than march: a false-reject direction.
  4. normalize — march's dedupes, ours does not. Latent: not on the verdict path.

Gaps 1 and 3 have teeth and point opposite ways. Both need hand-built probes.

Verification

Against march 6867c783 built from source, 277 files:

MATCH: 71   MISMATCH: 0   ERROR: 0   CORPUS_VIOLATION: 0
MARCH_SELF_INCONSISTENT: 0   SKIP: 203   SKIP-LEDGER: OK   RESULT: PASS

Ledger 177 → 203 entries, purely additive (zero stale). accept/t49_transitive_use_covered regressed MATCH → SKIP and is annotated as such: march#209 rewrote it to add a stdlib call, march accepts it, and march's own --emit-core-ast emits resolved_ty: TError for that call — H3 honest-skips on TError, which is correct. A3 slice (a) now holds 10 of the 11 files it claimed; the inconsistency is march-side.

Not done

P1 (de-partializing the 20 tree walks) and P2 (the three whole-checker theorem/counterexample pairs) from the design doc are outstanding. P2 depends on P1.

Ch4s3 added 18 commits August 8, 2026 15:29
Design for a proof layer over the shipping capability checker: abstract
lattice metatheory discharged by decide for march's 20-entry table,
verdict algebra, de-partialization of the 18 cap-relevant walks with
legacy-equivalence pins, and three whole-checker theorem/counterexample
pairs (tier order-independence, normalize-stability, IO-cap
monotonicity) — each true for the subsumption-coverage core and false at
the behavioral-cap boundary.
…le (P0b)

API-preserving: capParent/capAncestorsFuel/capAncestors/capSubsumes/
normalize become specializations of Calculus.parentIn/ancestorsInFuel/
ancestorsIn/subsumesIn/normalizeIn at the unchanged 20-entry hierarchy.
Adds WellFormedB (nodup names + closed parents + acyclic) and the
metatheory over any well-formed table: fuel adequacy, ancestor-chain
shape, subsumption partial order (refl/trans/antisymm), sibling
incomparability, FFI isolation, covered monotonicity, and normalize
idempotence + coverage-preservation.

Conformance output verified byte-identical to the pre-change baseline
over the local 242-file corpus.
…ng names (P0b)

Kernel decide discharges the real 20-entry table in ~1s, then every
abstract theorem is instantiated at capSubsumes/capAncestors/normalize.

Negative control verified: adding a self-parent row ("X.Cycle", some
"X.Cycle") to hierarchy fails hierarchy_wellFormed's decide and stops
the build, then reverted. Before P0 that same cycle would have made
capAncestors silently truncate with no error anywhere.
Two gaps, both of which would have let the proof layer rot while CI
stayed green:

1. CI built only `march-lean-check`, and MarchLeanCheck.lean imports
   individual modules rather than the MarchLean library root — so
   Calculus/Verdicts.lean and Calculus/Concrete.lean were outside the
   built import closure and were never kernel-checked. Both targets are
   now named.
2. Lean reports `sorry` as a warning, not an error, so `lake build`
   exits 0 with unproven theorems. Added a source grep gate.

Both verified locally: the build succeeds with the CI args, and the
guard was confirmed to fire on an injected sorry.
capsInSignature scanned only DFn parameters, so a Cap(X) named in a variant
constructor argument escaped needs coverage entirely — a false ACCEPT on
reject/t149_cap_variant_arg_undeclared against march main.

march builds one cap_uses list over every decl form and DType contributes
Cap_surface_ty.caps_in_type_def (typecheck.ml:8813); march's own comment
records these arms were previously swallowed by a wildcard, which is the
identical hole. Scanned ungated like parameters: the cap is named concretely,
so capsInReturnSignature's unmodeled-machinery gate does not apply.

TDRecord/TDAlias decode to Decl.unsupported, so reject/t148 and t150 reach
the same conclusion as whole-file skips rather than here.

Also ledgers reject/t144_cap_derive_json_variant_arg as a known limitation:
march rejects the "derive Json" over a capability position, but that derive
is erased by the desugarer and the residual (needs IO + type + main) is
well-typed with its Cap(IO) covered — verified by inspecting the emitted
Core AST, whose decl kinds are only DNeeds/DType/DFn. Same mechanism as the
existing t23/t49 entries.
march's R2 made the root capability granted to main at the boundary rather
than taken from an ambient global: naming root_cap is now an error
(typecheck.ml:5118-5133). This checker still modeled the pre-R2 world, so
reject/t152_root_cap_is_ambient_authority was a false ACCEPT — a module
could hold Cap(IO) with no diagnostic and narrow freely from there.

root_cap stays BOUND at Cap(IO) in Infer, matching march's deliberate choice
to keep the name bound so one mistake reports a single capability error
instead of cascading unification failures. Only referencing it is refused.

march exempts four contexts via env.root_cap_allowed: DTest, DSetup,
DSetupAll, and the REPL entry. All four decode to Decl.unsupported here, so
the check gates on the module being entirely in fragment — the same residual
gate retCaps already uses — rather than modeling the flag. That gate is
load-bearing, not defensive: cap checks run BEFORE the skip gate (A3 design
section 4), so accept/t146_root_cap_in_test_body would be a false REJECT
without it. Pinned by the rootCapInUnsupportedContext fixture.

Verified against march main: t152 reject/reject MATCH, t146 skips,
accept/t147_main_receives_the_root (which takes cap : Cap(IO) as a parameter
and never names root_cap) still accepts.
Check 1 read declaration signatures only, so a capability named by a type
annotation inside a body escaped needs coverage entirely — a false ACCEPT on
reject/t151_cap_body_annotation_undeclared against march main.

capAnnotsInTerm mirrors march's cap_annots_in_expr (typecheck.ml:8109-8180):
a let binding's annotation, and lambda / local-function parameter
annotations, folded into the same cap_uses list Check 1 already consumes.
Ungated like parameters — the cap is named concretely by an annotation the
author wrote, so capsInReturnSignature's unmodeled-machinery gate does not
apply.

A lambda parameter is the sharp case and the reason R2 alone was not enough:
naming the type needs no capability VALUE, so closing the ambient-root route
(see the preceding commit) does not close this one.

Total over every Term constructor with no wildcard arm, matching
bodyCalls/bodyAllocates/termMentionsAny here and march's own stated rule for
capability walks — a catch-all arm is a silent hole rather than a visible
bug, and a new Term form must break this build. march's EAnnot arm has no
counterpart (no such Term form); Term.letfn carries no return annotation, so
march's ELetFn return arm is likewise unreachable in this fragment. Both
noted in the docstring rather than left to be rediscovered.

Verified against march main: t151 reject/reject MATCH, and
accept/t145_cap_let_annotation_covered still accepts, so the walk does not
over-reject a covered annotation. Nested coverage (tuple > match arm >
lambda) pinned by the bodyAnnotNestedUncovered fixture.
march's R4a retyped cap_narrow from Cap(IO) -> Cap(a) to a fully polymorphic
Cap(a) -> Cap(b), so a holder of something narrower than the root can
delegate with attenuation. This checker still demanded the argument be
literally Cap(IO) and rejected accept/t148_cap_narrow_chains with "cannot
unify IO with IO.FileSystem" — a false REJECT.

The dangerous half is what the retype does NOT do. Before R4a the argument
type enforced subsumption through unification, and reject/t153 (widen), t154
(siblings) and t155 (widen visible only after later unification) all
rejected as a SIDE EFFECT of that unification failure — nothing consulted
the lattice. Retyping alone would therefore have converted one false reject
into three false accepts. march moved the guarantee into a deferred sweep
(check_cap_narrow_sites, typecheck.ml:9406-9432); CapCheck.capNarrowViolation
is the mirror, and all three files now reject because the lattice says so.

The rule as march states it: an error iff BOTH sides resolve to concrete
lattice capabilities AND the source does not subsume the target. Reflexivity
is allowed, which is what makes t148's same_level legal. An unpinned side is
silent — a result never pinned to a concrete capability is never USED as one.
concreteLatticeCap requires hierarchy membership, which subsumes march's
explicit proof-cap exemption and also excludes FFI caps; that difference can
only make this checker more permissive, never less, so it cannot manufacture
a false reject.

Reading both sides off the application node works because the emitter's
resolved_ty is POST-solve — verified on t155, whose cap_narrow application
resolves to Cap(IO.FileWrite) with argument Cap(IO.Console), exactly the
deferred widen march's sweep waits for.

Also makes Infer honor a dfn's surface RETURN annotation, as it already
honors parameter annotations. march CHECKS a body against its declared
return type, so an annotated return has that type by definition. Skipping it
was invisible while every builtin's result was pinned by its arguments; R4a
broke that, and in `pfn same_level(r : Cap(IO.FileRead)) : Cap(IO.FileRead)
do cap_narrow(r) end` the result is pinned ONLY by the annotation, leaving a
metavariable where march resolved a concrete type (exit 4, types_differ).

Verified against march main: t148 accept/accept, t153/t154/t155
reject/reject, accept/t52_cap_narrow_multi still accepts. The five R4a
fixtures pin widen, siblings, attenuate, same-level, and the proof-cap
exemption independently of the corpus.
…iles

Bumps the pin 71 commits, from 7c1d701c. Unlike the previous two bumps this
drift was NOT inert: it surfaced five corpus-visible divergences, four of
them false ACCEPTS, each fixed in the four commits preceding this one. The
pin comment now records the assessment in full.

format_version is still 3 and module_caps is still emitted, so there is no
emitter-version dance and no two-repo sequencing — the checker reads main's
output as-is.

Ledger: 177 -> 203 entries, purely additive (zero stale entries), tracking
the corpus's growth from 242 to 277 files. Nine of the 26 additions are
capability rejects this checker still cannot judge; a skip is not a pass.

One entry is a REGRESSION rather than a new file and is annotated as such:
accept/t49_transitive_use_covered was MATCH and now skips, so A3 slice (a)
holds 10 of the 11 files it claimed. Not a checker regression — march#209
rewrote the file to add the reference its new per-function Check 4 requires,
march ACCEPTS the result, and march's own --emit-core-ast emits resolved_ty
TError for that stdlib call. H3 honest-skips on TError, which is correct.
The inconsistency is march-side.

Also records four gaps against this pin that the corpus is structurally
incapable of catching, so a green run is NOT evidence about any of them
(specs/march-findings.md, and summarized in
specs/plans/2026-08-08-march-main-capability-resync.md):

  - path-scoped capabilities decode as unscoped — a false accept BY
    CONSTRUCTION, with zero corpus files using the syntax;
  - capsInTy still skips Tagged, mirroring an arm march deleted;
  - Check 4 still uses the pre-march#209 whole-module rule, making this
    checker strictly STRICTER than march — a false-reject direction;
  - normalize does not dedupe (latent: not on the verdict path).

The first and third have teeth and point opposite ways. Both need
hand-built probes.

Verified against march main: MATCH 71, MISMATCH 0, ERROR 0,
CORPUS_VIOLATION 0, MARCH_SELF_INCONSISTENT 0, SKIP 203, ledger OK.
…vioral layer (P2)

Two properties that sound like they should hold of checkCaps — replacing
needs L by needs (normalize L) never changes the verdict, and adding a
capability to needs never flips accept into reject — are TRUE for the
subsumption-coverage core and FALSE for the full checker. This proves both
halves of each.

The boundary is the same mechanism twice: cap no_extern reads the needs list
for a property of the LIST ITSELF (hasForeignNeed) rather than asking what
the list covers. A check that inspects the syntax of a declaration set rather
than its denotation cannot be stable under an operation that preserves only
the denotation — and normalize is exactly such an operation
(coveredIn_normalizeIn).

So these are not curiosities to work around. They say: normalize may never be
applied to a needs list before a behavioral-cap check runs. Nothing does that
today; this makes it a checked invariant rather than an accident.

The counterexamples are sharper than "the verdict differs":

  - normalize ["IO", "IO.Foreign"] = ["IO"], because IO.Foreign's parent is
    IO. The two lists are coverage-EQUIVALENT (proved, not asserted), so no
    coverage-based check could tell them apart — yet the verdict moves
    violation -> ok.
  - For monotonicity, the ADDED capability is already covered by IO
    (added_cap_was_already_covered), so it grants no new authority
    whatsoever, and the verdict still flips ok -> violation. Declaring a
    capability is itself observable behavior at the behavioral layer:
    no_extern objects to the DECLARATION of foreign authority, not to
    holding it.

Proof-strength split, deliberate and documented in the file: the mechanism is
kernel-checked with no axioms at all (normalize_absorbs_foreign,
added_cap_was_already_covered are pure computation over the total
CapLattice), while the end-to-end checkCaps verdicts are pinned by
native_decide, because checkCaps runs through partial defs and has no
equation lemmas for the kernel to unfold. hasForeignNeed also needs
native_decide: it splits on ".", and String.splitOn does not reduce in the
kernel. The mechanism is the part that generalizes; the end-to-end pin is
what proves the mechanism actually reaches a verdict.

Repo theorem count 55 -> 64. Additive only: no shipping definition changes,
so the conformance corpus is unaffected.

The remaining P2 theorem — verdict-tier order-independence — still needs P1
(de-partializing the tree walks), since it must induct over checkDecls.
…ctor (P1)

Syntax.lean's tree walks were partial defs recursing through List.any/List.all
— nested inside a higher-order call, which Lean's structural-recursion checker
cannot see through. Each is now a mutual block pairing the walk with an
explicit List helper, which makes the descent structural.

partial def is not free. It compiles to an opaque constant, so the kernel can
neither unfold it nor induct on it. Two consequences this commit removes:

  - Every test over these walks said native_decide, i.e. trusted the compiled
    evaluator rather than the kernel. All 15 in this file now say decide.
    That is a trust hole closed, not a style change.
  - No theorem about them was possible at all. P2's whole-checker properties
    need induction over exactly these walks.

Term.hasUnsupported needed more than a helper: its
`| t => t.ty.hasUnsupported || (match t with …)` shape binds the whole term in
the catch-all, so Lean cannot see the inner match's calls descending. Every
constructor now names its own arm and repeats the node's ty check explicitly.
flattenDecls is the one genuine non-structural case — the dmod arm recurses
into a list nested INSIDE the head element, not a tail — so it terminates on
sizeOf instead, via termination_by/decreasing_by.

MarchLean/Calculus/Walks.lean argues the refactor is behavior-preserving, and
argues it more strongly than planned. The design called for keeping each old
body as a legacy twin and pinning `legacy x = new x` on fixtures. Instead each
helper is PROVED equal to the exact fold it replaced, by induction, for every
input:

    Ty.anyHasUnsupported ts        = ts.any Ty.hasUnsupported
    Ty.beqList xs ys               = (xs.length == ys.length && (xs.zip ys).all …)
    Term.anyArmHasUnsupported arms = arms.any (fun (p, g, e) => …)
    Decl.anyCtorHasUnsupported cs  = cs.any (fun c => …)
    … 15 in total

A fixture pin can only say "the two agree on what we thought to try"; these
say the two are the same function. Each theorem is literally the diff of the
refactor stated as an equation, so a future edit that drifts from its fold
stops compiling.

Two of these deserve their names read carefully. ty_beqList_eq shows the
length check was not lost when `length == length && zip` became a structural
walk — it is exactly the two fall-through arms. term_anyArmHasUnsupported_eq
states the arm walk INCLUDING its pattern component, which was dropped once
before and was a real false accept.

Also updates the stale comment in the test block that still explained why
these tests needed native_decide. It had become false, which is the precise
failure mode this project keeps paying for.

Repo theorem count 64 -> 78. Syntax.lean now has zero partial defs;
CapCheck.lean's 14 are the next step. Conformance re-verified: no shipping
behavior change.
Five conflicts, three of them semantic rather than textual. How each was
resolved, since "took both sides" would be wrong for most of them:

MarchLean.lean — import list. Kept both (TailCall plus the three Calculus
modules).

Infer.lean, hunk 1 — main kept the pre-R4a `cap_narrow : Cap(IO) -> Cap(a)`
and added `println : forall a. a -> ()`. BOTH are needed: this branch retypes
cap_narrow to `forall a b. Cap(a) -> Cap(b)` (R4a), and main's println fix is
unrelated and correct. `capIO` stays defined because `root_cap` still uses it.

Infer.lean, hunk 2 — main and this branch independently made the SAME fix
(check a fn body against its declared return type), from opposite directions:
main found it as a false-accept class across return-position mismatches
(`: String` over an Int body, nominal aliases, and five more, each verified
against march); this branch hit it because R4a's polymorphic cap_narrow leaves
`same_level`'s body pinned only by the annotation. Took main's comment — it
documents far more verified cases — and added the R4a interaction, which
main's version does not mention and which `accept/t148` now depends on.

Syntax.lean — the substantive one. main added a `Term.opaque_` constructor;
this branch replaced Term.hasUnsupported's catch-all with explicit
per-constructor arms (P1 de-partialization). Kept the structural version plus
main's `.opaque_ => true` arm and its rationale.

That resolution then FAILED TO BUILD, which is the point: CapCheck's two new
exhaustive walks (capAnnotsInTerm, capNarrowViolation) had no `.opaque_` case,
so a new constructor broke the build instead of silently falling through a
wildcard. Both now recurse into `children`, matching the convention main's own
walks use (bodyCalls, bodyAllocates, matchesIn) — which is the whole point of
decoding EAnnot to opaque_ so its children survive to the cap layer.

march-findings.md — main marked the march#82 finding FIXED UPSTREAM (merged as
9a373001), superseding this branch's stale "reported upstream: NOT YET". Took
main's status and appended this branch's "gaps the corpus CANNOT catch"
section after it.

expected-skips.txt — main added ledger sections for the three corpora its new
--lang-dir option sweeps (grammar/parse, grammar/reject, golden); this branch
added 26 entries for the 277-file types corpus. Unioned, then re-baselined
against a real run rather than trusting the union, since main's fixes
(opaque_ decoding, exhaustiveness, println, EAnnot) and this branch's P1
change both move which files skip.

Verified after resolving: all four gaps this branch records in
march-findings.md are still accurate (Elab still drops `scopes`, capsInTy
still has `| .con "Tagged" _ => []`, Check 4 still uses the whole-module
rule), so none became stale across the merge. All 13 capability fixtures
(Check 1 type-decl and body-annotation, R2, the five R4a cases) report
unchanged verdicts. CI pin 6867c783 and the sorry/admit guard both survived
the auto-merge.
…to ERROR

The tail-call probes were authored against pin 7c1d701c, where a builtin call
in a function body requiring an undeclared capability was a WARNING. This
branch bumps the pin to 6867c783, where march made it an ERROR
(typecheck.ml:9098, Err.error_with_fix: "function body calls a builtin that
requires Cap(IO.Console) but M does not declare needs IO.Console").

Every probe calls println and none declared needs, so under the new pin march
rejected all 22. The probe harness reported it exactly as designed — "march
exit 1, expected 0 — probe has rotted" — 18 times.

The other four are the more interesting half. They are the tailcall-class
probes, which EXPECT march to reject, so they kept passing — for the wrong
reason. march was rejecting them on the capability error, not on tail-call
enforcement, so the thing they exist to test was no longer being tested at
all. Adding needs IO.Console restores that: they now reject on tail calls
again, and the clean probes accept.

Probes are about tail-call enforcement; their lack of a capability manifest
was incidental, so declaring it is the minimal fix and does not weaken them.

Local run against march 6867c783: pass=22 fail=0.

The promotion itself is a finding about THIS checker, not just about the
probes — march-lean does not implement Check 1b at all, by an explicit A3
design decision that is now obsolete. Recorded separately in
specs/march-findings.md.
march main 6867c783 raises Check 1b with Err.error_with_fix, not Err.warning
(typecheck.ml:9098). A3 design decision 1.3 deliberately skipped 1b/1c on the
grounds that they were WARNING-only and implementing them "would manufacture
false MISMATCHes against a march that accepts". That reasoning was correct
when written and is now obsolete: march closed the hole its own docs called
"the single most consequential fact for anyone relying on needs as a
soundness guarantee", and this checker did not follow.

So what the design recorded as a shared weakness is now a DIVERGENCE, in the
false-ACCEPT direction: a module calling an IO builtin in a body without
declaring the capability is rejected by march and accepted here.

Annotates the A3 decision in place rather than only recording the finding
elsewhere — a superseded decision that still reads as current is exactly how
this codebase has been bitten before.

Worth reading the "how it was found" note. The 277-file corpus reports
MISMATCH 0 against this same pin, because every corpus file declares its
capabilities properly. The class surfaced only because the tail-call probes
were hand-written WITHOUT capability manifests. A green corpus run said
nothing at all about it; eighteen throwaway probes found it immediately.

Not fixed here. The machinery exists (builtinCaps maps builtin -> cap,
bodyCalls already walks bodies), but march's exact gating needs care:
implementing it slightly too eagerly turns a false-accept class into a
false-reject class.
The fix shape said implementing 1b too eagerly would flip a false-accept
class into a false-reject one. That was true but vague enough to be useless
to whoever picks it up. Names the three concrete hazards instead, each
verified against march 6867c783: bodyCalls matches by NAME with no scope
awareness (and the corpus already has shadowing cases that would fire);
march scopes 1b to DIRECT builtin calls, so a stdlib-mediated call must not
be scanned; and 1c stays a warning, so the two must not be flipped together.
@Ch4s3
Ch4s3 merged commit ae3f471 into main Aug 10, 2026
1 check passed
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