Skip to content

feat(caps): close the coverage gap opened by march#136 — Term.opaque_ over nine AST kinds - #14

Merged
Ch4s3 merged 2 commits into
claude/repin-march-baselinefrom
claude/cap-opaque-coverage
Aug 3, 2026
Merged

Ch4s3 merged 2 commits into
claude/repin-march-baselinefrom
claude/cap-opaque-coverage

Conversation

@Ch4s3

@Ch4s3 Ch4s3 commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Stacked on #13 (claude/repin-march-baseline). GitHub will retarget this to main once #13 merges.

Close the capability coverage gap opened by march#136

march made calls_in_expr total over Ast.expr (march#136 — the fix for the blind spot this oracle reported). That function is the shared body-walk behind check_pure_module, check_deterministic_module, check_no_panic_module, and Check 8.

Consequence: march now detects capability violations inside AST constructs our decoder maps to Term.unsupported, so we skip where march rejects. Safe — never a false accept — but a skip detects nothing, and march was ahead of us on nine constructs.

The nine gaps

ECond, ERecordUpdate, EAtom, EAssert, EDbg, ELetFn, ELetQ, ESend, ESpawn — each confirmed by probe to reject for the capability reason (not an incidental type error) while we exited 2.

Deliberately excluded, with reasons:

  • EPipe, ESigil — not gaps. Desugar eliminates them before emission (desugar.ml:543-586, :733-744); both tools already reject those shapes. Decoder arms would be dead code.
  • EAnnot, EResultRef — no parser productions; unreachable from source files.
  • EHole — a leaf in calls_in_expr; no capability can hide in it.

Mechanism: one cap-transparent constructor

The naive fix — decode these into fully modelled Term constructors — was rejected. It would make Decl.hasUnsupported false, so Compare.lean:286 stops skipping the file, Infer then judges constructs it has no typing rules for, Infer.lean:870 throws, and Compare.lean:296-300 converts that throw into a false reject. That is the exact regression mechanism from the previous slice.

Instead this adds a single Term.opaque_ (children : List Term) (ty : Ty) that:

  • carries its child expressions, so the cap-layer walks (bodyCalls, bodyAllocates, divisionVerdict, matchesIn) recurse into them and can render a violation; and
  • hard-codes hasUnsupported = true, so the file still skips at Compare.lean:286 exactly as today — inference and linearity never see it.

This works because MarchLeanCheck.lean runs checkCaps before inferModule, and a .violation returns exit 1 immediately. Net: skip→reject for violations, everything else unchanged, zero false-reject exposure.

The ordering dependency is now load-bearing for nine checks rather than one. The comment at CapCheck.lean:396-405 has been updated to say so explicitly: reorder the driver and opaque_ degrades to silent, not conservative.

Walk-arm reasoning

Governing rule: walks running before the skip gate recurse into children; walks running after it are unreachable and mirror .unsupported.

  • bodyCalls → children.any. Mirrors calls_in_expr; can only ever add a true, i.e. only ever turn a skip into a reject march also renders. This is the arm that closes the gap.
  • bodyAllocates → children.any; the node itself allocates nothing. Verified against no_alloc.ml: march deliberately does not flag ERecordUpdate (unlike ERecord), so this must not copy .record's true.
  • divisionVerdict → recurses with both channels emptied. march's iter_div_sites retires ELetFn/ELetQ binders and pushes ECond path conditions; opaque_ records neither, so carrying outer facts in would be unsound toward false reject (a stale d = 0 leaking into a rebinding body).
  • matchesIn → children.flatMap (pure collector; matchExhaustive remains the judgement site).
  • divisorVerdict → .unknown; Compare.termSpanTys → []; Linearity.uses → 0; capturedInLam → false; checkTerm → .ok; Infer.infer → throw. All judgement sites or unreachable behind the gate.

Testing

total files: 242   MATCH 63   MISMATCH 0   ERROR 0
SKIP 177 (88 accept / 89 reject)   SKIP-LEDGER OK   RESULT PASS

The corpus cannot regression-test any of this. A census of all 242 files found none combining a cap directive with a capability violation inside any of these constructors — the harness numbers are identical before and after this change. The 23 new native_decide fixtures are the only coverage that exists. (Third time this pattern has held for this repo.)

Probe table — each construct, violating variant and near-miss (same construct, no banned call):

construct violating: march / before / after near-miss: march / before / after
ECond 1 / 2 / 1 0 / 2 / 2
ERecordUpdate 1 / 2 / 1 0 / 2 / 2
EAtom 1 / 2 / 1 0 / 2 / 2
EAssert 1 / 2 / 1 0 / 2 / 2
EDbg 1 / 2 / 1 0 / 2 / 2
ELetFn 1 / 2 / 1 0 / 2 / 2
ELetQ 1 / 2 / 1 0 / 2 / 2
ESend 1 / 2 / 1 0 / 2 / 2
ESpawn 1 / 2 / 1 0 / 2 / 2

Controls unchanged: println in a plain block under cap pure → reject; 10 / 0 under no_panic → reject; 10 / 2 → accept. No near-miss returns 1.

Known residuals (all conservative — lose detection, never risk a wrong verdict)

  1. opaque_ in application-function position is not reached ({ p with … }()), so march rejects and we skip. Argument position is fine. Being fixed as a follow-up.
  2. divisorVerdict → .unknown for an opaque divisor: 10 / dbg(x) under no_panic is a march reject, our skip.
  3. ECond path conditions are not recorded, so a guard in a cond arm cannot discharge a divisor in that arm — weaker no_panic coverage inside cond than inside if/match.
  4. ESpawn's violating shape isn't reachable in well-typed code (march requires a bare actor name); included because calls_in_expr walks it.

Ch4s3 added 2 commits August 3, 2026 12:51
march made `calls_in_expr` total over `Ast.expr` (march#136). It is the
shared body-walk behind `check_pure_module`, `check_deterministic_module`,
`check_no_panic_module` and Check 8, so a capability violation can hide
inside an AST construct this fragment does not model. Nine such kinds were
decoding to `Term.unsupported`, which DISCARDS the children: the cap layer
could not see the violation and the file exited 2 where march exited 1.

Naively decoding these into real modelled `Term` constructors is the wrong
fix and is what produced the previous slice's false rejects: it makes
`Decl.hasUnsupported` false, `Compare.lean`'s skip gate stops firing, `Infer`
then judges constructs it has no typing rules for, throws, and
`Compare.inferModule` converts that throw into a REJECT march never renders.

Instead this exploits the driver ordering. `MarchLeanCheck.run` invokes
`CapCheck.checkCaps` BEFORE `Compare.inferModule`, and a `.violation` returns
exit 1 immediately. So a new constructor

    Term.opaque_ (children : List Term) (ty : Ty)

carries exactly the sub-expression list march's own walk descends into, while
`Term.hasUnsupported` HARD-CODES `true` for it. The file therefore still hits
the whole-file skip gate exactly as before — `Infer` and `Linearity` never
see an `opaque_` node — so the change has zero false-reject exposure, and the
only pass that can act on it is the cap layer that runs ahead of the gate.

Kinds decoded (child fields read off the emitter `lib/dump/ast_json.ml`
:379-503, not guessed; table in `decodeTerm`'s docstring):
ECond, ERecordUpdate, EAtom, EAssert, EDbg, ELetFn, ELetQ, ESend, ESpawn.

Deliberately NOT included: EPipe/ESigil (desugar eliminates both before
emission, `desugar.ml:543-586`/`:733-744` — arms would be dead code);
EAnnot/EHole/EResultRef (no reachable parser production / leaf nodes with no
sub-expression a capability could hide in).

Per-site reasoning for the new walk arms. The governing rule: a walk that
runs BEFORE the skip gate recurses into `children`; a walk that runs AFTER it
is unreachable on any module containing an `opaque_`, so it mirrors
`.unsupported` exactly and provably changes nothing.

BEFORE the gate (recurse):
* `CapCheck.bodyCalls` — `children.any`. This is the direct mirror of march's
  `calls_in_expr`, which walks all nine kinds (`typecheck.ml:7734-7750`).
  Recursing can only ADD a `true`, i.e. only turn a skip into a reject march
  also renders.
* `CapCheck.bodyAllocates` — `children.any`, contributing NO allocation of its
  own. Verified against `no_alloc.ml`: its four allocating arms are
  ETuple(_::_)/ERecord/ECon(_,_::_)/ELam, none of the nine, and it has an
  explicit recurse-only arm for each (`no_alloc.ml:41-63`). Note march does
  NOT treat ERecordUpdate as an allocation even though ERecord is one, so
  this arm must not answer `true` the way `.record` does.
* `CapCheck.termMentionsAny` — `children.any`. This is the deliberately
  over-approximate `expr_mentions`, used only to DISCARD a path fact on a
  rebinding; over-approximating loses information rather than inventing a
  proof, so recursing moves strictly in the safe direction. Leaving it
  `false` would let a stale guard survive a rebinding hidden in an
  ELetQ/ELetFn child and license a WRONG non-zero proof.
* `CapCheck.divisionVerdict` — recurses with BOTH channels EMPTIED. march's
  `iter_div_sites` walks all nine but knows their shape: ELetFn/ELetQ retire
  the names they bind, ECond pushes each arm's condition onto `path`.
  `opaque_` records neither, so carrying the outer facts/path in unchanged
  would be unsound toward false REJECT (a stale `d = 0` surviving into a
  rebinding ELetFn body would manufacture a divZero). Empty facts + empty
  path is conservative both ways: what survives is exactly march's two
  unconditional judgements — literal-zero divisor (arm 1) and complex divisor
  (arm 4).
* `CapCheck.matchesIn` — `children.flatMap`. Pure collector; a non-exhaustive
  match nested in a `cond` arm is as much a panic surface as a top-level one,
  and `matchExhaustive` remains the conservative judgement site.

JUDGEMENT site, held back:
* `CapCheck.divisorVerdict` (`.opaque_` AS the divisor, e.g. `10 / dbg(x)`) —
  `.unknown`. All nine do land in march's arm-4 catch-all today, so rejecting
  would be right, but this is a judgement, not a collector, and answering
  `unknown` keeps the verdict identical to pre-`opaque_`. Tightening it is a
  separate, separately-verified change.

AFTER the gate (unreachable; mirror `.unsupported`):
* `Compare.termSpanTys` — `[]`. Runs in `inferModule` step (3), after the
  step-(1) skip gate. Recursing would also be harmless but would imply these
  spans participate in the resolved_ty cross-check; they never can, since
  their children are never inferred.
* `Linearity.uses` — `0`. Unreachable (skip gate precedes `checkLinearity`),
  and additionally the SAFE half of the unreachable pair: `opaque_`'s children
  are an unordered bag with no recorded mutual-exclusivity (an ECond's arms
  are mutually exclusive like a match's, but nothing records that), so summing
  would over-count a linear binder used once per arm into a bogus reject.
* `Linearity.capturedInLam` — `false`, consistent with `uses`'s 0.
* `Linearity.checkTerm` — `.ok`; cannot mask anything, the file exits 2 first.
* `Infer.infer` — `throw`, identical to `.unsupported`: reaching it would mean
  the gate had failed, and a loud failure is what that wants.

The load-bearing comment on `bodyCalls`'s `.unsupported` arm is extended to
restate the ordering dependency, which this slice DEEPENS rather than merely
inherits: if the driver is reordered so the skip gate precedes `checkCaps`,
`Term.opaque_` stops detecting anything — it does not degrade to
conservative, it degrades to silent.
The 242-file conformance corpus cannot regression-test any of this: a census
found NO file combining a `cap` directive with a capability violation inside
`ECond`/`ERecordUpdate`/`EAtom`/`EAssert`/`EDbg`/`ELetFn`/`ELetQ`/`ESend`/
`ESpawn`. A green harness run is no evidence here, so these hand-built,
self-contained `#eval`/`native_decide` pins are the only coverage.

Each fixture reproduces the exact CHILD-LIST ARRANGEMENT `Elab.decodeTerm`
produces for that `kind` — that arrangement, not the node's shape (which is
not modelled), is the whole contract between decoder and cap layer.

Every violating shape was verified end to end against the real march binary
(march rejects naming the capability; `--emit-core-ast | march-lean-check`
now exits 1 where it exited 2). Every near-miss counterpart was verified to
be ACCEPTED by march and must NOT produce a violation — a violation there
would be a false reject.

Also pinned beyond the nine:
* `Term.opaque_ _ _ |>.hasUnsupported = true` — the invariant the whole
  design rests on. If this ever flips, `Infer`/`Linearity` start judging
  these nodes and every fixture above becomes a false-reject risk.
* `no_panic`: a literal-zero divisor inside a child rejects; a non-zero one
  does not.
* `no_panic`: an outer `let d = 0` fact is NOT carried into an `opaque_`
  child — `10 / d` there is a SKIP, not a reject. This pins the
  empty-facts/empty-path recursion, whose whole purpose is that `opaque_`
  records no binders and a child kind (ELetFn/ELetQ) may rebind `d`.
* `no_alloc`: an allocation nested in a child is found, and the `opaque_`
  node itself allocates nothing — the ERecordUpdate case, where march flags
  `ERecord` but deliberately not `ERecordUpdate`.
* `no_panic`: a non-exhaustive match nested in a child is reached.

Regression controls re-verified against the built binary: `println` in a
plain block under `cap pure` still rejects (1/1); `10 / 0` under `no_panic`
still rejects (1/1); `10 / 2` still accepts (0/0).
@Ch4s3
Ch4s3 merged commit 36cb225 into claude/repin-march-baseline Aug 3, 2026
1 check passed
Ch4s3 added a commit that referenced this pull request Aug 8, 2026
Land PRs #14-#16: opaque_ coverage, ELet decode fix, println fix, 334-file harness
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