Skip to content

Link higher-order, class and platform boundaries in the Corsa check - #72

Open
mizchi wants to merge 9 commits into
mainfrom
corsa-dispatch-and-platform-contracts
Open

mizchi wants to merge 9 commits into
mainfrom
corsa-dispatch-and-platform-contracts

Conversation

@mizchi

@mizchi mizchi commented Sep 15, 2026

Copy link
Copy Markdown
Owner

The default Corsa check resolved only free-function calls and exact Owner#member contracts, so a class-based, callback-heavy browser codebase reached almost nothing. Four mechanisms close that, each fail-closed by construction.

Higher-order composition

A function that invokes one of its own plain-identifier parameters carries InvokeUserCode, and every call of that boundary — a plain call, a construction, super, or in-class dispatch — owes an analyzed boundary at that argument position or becomes unknown. Obligations propagate to a fixed point, so a caller that forwards its own parameter inherits one rather than discharging it.

Inherited contracts

A reviewed contract is keyed by the interface that declares the member and is now reached through the inheritance lib.dom.d.ts itself declares: Node#ownerDocument applies to an HTMLElement receiver, while a receiver that reaches no interface carrying that member stays unknown — NodeList#length still never selects Storage#length. Three interfaces whose named members the standard leaves open (CSSStyleDeclarationBase, DOMStringMap, DOMTokenList) carry one whole-surface contract, consulted only after every member-specific key on the chain has missed.

Class construction and dispatch

new C(), super(...) and super.m(...) link to the class body they run. this.m() and a member call on a nominal class link to the declared body only when nothing in the analyzed files can replace it: a private or # member always qualifies; a public one additionally needs no subclass declaring that name, no assignment that could install a function on that receiver type, an unnarrowed file list, and every statically imported binding declared in a file the run read.

Reviewed contracts

The catalog gains the legacy request object, the document tree, the window surface, the style, dataset and class-list surfaces, the URI functions, and the standard constructors and conversions. mutate is read as the ownership fact it is rather than an unmodelled primitive, clone and transfer render as their capabilities, and a contract's callback may be the function assigned to a handler property.

Review

Five independent adversarial reviews ran against this change — three spec reviews of the catalog and two attempts to break the linking mechanisms by execution. They reproduced 10 unsound proofs and over 40 contract defects; every one is fixed and pinned by a regression test. The ones worth naming:

  • PromiseLike#then had been given Promise.prototype.then's semantics, though it is a structural interface any value with a then method satisfies, so the call is synchronous, arbitrarily repeating user code.
  • DOMRect redeclares DOMRectReadOnly's geometry members as writable, so the inheritance walk landed on the read-only entry and proved that assigning them was harmless.
  • A TypeScript this parameter shifted every argument index, silently dropping the obligation it named.
  • new C(cb), this.m(cb) and super.m(cb) never discharged the callee's obligation at all.
  • An anonymous class extends Base, extends Alias, a mixin call, a decorator, C.prototype.m = f and this.m() written inside an object literal each defeated override detection.

Over-claims were corrected in the same pass, which is why some effect counts go down: new Error("literal") no longer carries InvokeUserCode (the declared message is a string, so its coercion is total), and the Event constructor and new Promise no longer carry a TypeError that typed code cannot reach.

Measured

On a 224-file browser ad delivery codebase, analyzed with no changes to it, counting production sources only:

before after
unknown 1,094 892
trusted 17 175
inferred 299 350

Net becomes observable for the first time. This repository's own check runs in 29 s against 33 s before, while reporting 75 syntax errors where the earlier build reported 1,024.

One limit is worth recording because it is a property of the analyzed codebase rather than of the checker: 576 member-call sites there are blocked by a single deep-merge helper whose target parameter is any and whose assignment is computed. That write could install a function on any object, so no public method of any class can be proved un-replaced.

Verification

  • fast tier 95 files / 1,718 tests, Corsa and catalog suites 25 files / 324 tests, all passing
  • tsc --noEmit across all four project configurations
  • ci/check-examples.mjs and ci/check-skills.mjs pass
  • The detection oracle still fires: the unrepaired handler reports its unguarded parts[1], the repaired one does not
  • The integration tier's 53 failures are byte-identical to clean main — a 2 GB WASM limit in the Z3 backend, unrelated to this change

🤖 Generated with Claude Code

mizchi and others added 9 commits September 14, 2026 00:52
Invoking a parameter, constructing a class, dispatching through `this` or
`super`, and reaching a member a base interface declares each resolve to the
body that runs, under conditions that fail closed where a body could have been
replaced. The reviewed catalog gains the legacy request object, the document
and window surfaces, the style, dataset and class-list surfaces, the URI
functions, and the standard constructors and conversions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adversarial review reproduced 16 unsound proofs in the public-method side of
class dispatch. Linking `this.m()` and a call on a receiver the caller supplies
needed the analyzed file set to be closed, and a barrel re-export, a bare
side-effect import, a dynamic `import()`, a triple-slash reference and an ambient
subclass declaration each leave it open; the nominality argument failed as well,
because an optional `private` member imposes nothing on an object literal.

Only what is exact remains: `new C()` and `super(...)` run the constructor they
name, and `this.#m()` runs the body its declaration fixes, because a `#` name is
not a property. A property site whose member the checker resolves to no
declaration is now unknown rather than a silent proof.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A class body this path cannot identify now resets the scope instead of leaving
the enclosing class's, so `this.#m()` and `super()` inside an anonymous nested
class no longer link to the outer class's declarations. A call through a frozen
effect table and an immediately invoked function now supply their arguments to
the discharge pass the way a plain call does. A boundary that owes an obligation
of its own cannot discharge another, because whatever fills its parameter is
chosen inside its callee rather than at the call.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…owed boundaries

A static block and a static field initializer run when the class declaration is
evaluated, not at construction, so they no longer open the construction boundary
a sibling instance initializer opens; where that boundary already covers them by
span, the scope that declares the class is unresolved instead of a proof.

Composing a boundary as a value is not calling it. A contract's callback
argument, a handler assigned to a property, and an argument that discharges an
invoked-parameter obligation are all supplied by whoever calls that boundary
elsewhere, so one that still owes an obligation of its own cannot be composed
here. The rule previously applied only to an inline argument.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A construction located its boundary by probing one character past the class
body's brace and accepting whatever fact had a name ending in "constructor".
That missed an `accessor` field, matched a nested class of the same name, and
fell through when the first member abutted the brace — each time returning an
empty target list, which is a proof that construction performs nothing.

One rule now gives both the syntax pass and the checker the same span, a class
whose body the run did not resolve to a boundary is unresolved rather than
effect-free, and a decorator anywhere on the class or its members withdraws the
link, because its return value replaces what the construction runs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…tart

A call expression shares its start offset with every call chained onto it, so
`this.#m().write()` matched the private-call entry twice and linked the outer
`.write()` to `#m`'s body. That kept the outer site off the unresolved path
entirely, proving a method that writes a cookie performs nothing. The callee
token is unique to each site and is what a call site already reports.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The construction boundary widens over the whole class body once an instance
initializer needs it, so a decorator expression and a computed member key — both
evaluated when the declaration is evaluated, not when an instance is constructed
— were charged to a constructor that never runs them, leaving the function that
declares the class an empty proof. The declaring scope is now unresolved
whenever that widened boundary would cover definition-time work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… by callee

A class decorator runs before the class is defined, in the scope that declares
it, so its `#` names and its `super` are the enclosing ones; the walk was giving
it the decorated class's scope and composing the wrong body. A frozen effect
table's call was keyed by the call expression's start offset, which every call
chained onto it shares, so `table.open().write()` linked the outer call to the
frozen member and lost the real callee. Both now use the token a call site
reports as its callee.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…stic

An exclusion is a construct this path recorded no site for: a conditional or
coalesced callee, a conditional constructee, a tagged template. The boundary
containing one therefore composed nothing and stayed a proof of effect freedom
while the construct ran — including the invoked-parameter obligation a callee
would otherwise have owed. Two exclusion reasons already failed closed; all of
them do now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@mizchi

mizchi commented Sep 15, 2026

Copy link
Copy Markdown
Owner Author

Pre-merge review found 16 unsound proofs; the leaky half is cut

Five adversarial reviewers re-attacked this branch by execution before merge. They reproduced 16 unsound proofs — cases where a summary claimed inferred/trusted with an effect set the function actually exceeds at runtime. Fourteen of them were in one mechanism, and the pattern was not a bug list but a design verdict, so that mechanism is now cut rather than patched.

What was cut

Linking a public this.m(), and a member call on a receiver the caller supplies, both needed the analyzed file set to be closed. That precondition was implemented by walking ImportDeclaration specifiers, and reviewers defeated it five different ways — a export * from barrel, a bare side-effect import "./sub.js", a dynamic import(), a triple-slash /// <reference path>, and an ambient .d.ts subclass declaration each leave a file unread while a subclass in it overrides the method. The nominality argument for the receiver case failed independently: an optional private tag?: number imposes nothing on an object literal, so { run() { fetch(...) } } type-checks as that class and its body is what runs. Six more findings showed the member-write invalidation missing Object.assign(C.prototype, …), Object.defineProperty, delete, writes through a structurally-compatible alias, writes through a base-typed receiver, and a write whose value is cast to a function.

super.m() went with them: it names a base declaration but nothing proved that declaration is still the body.

What remains, and why it needs no assumption

  • new C() runs C's constructor, or the inherited one when it declares none. Exact.
  • super(...) runs the base the extends clause resolves to. Exact.
  • this.#m() runs the body its declaration fixes. A # name is not a property: no subclass redeclares it, no code outside the class body writes it, and Object.assign, Object.defineProperty and delete cannot reach it.

Two further fixes came out of the same review: a property site whose member the checker resolves to no declaration — a nullable receiver, an any, an index signature — is now unknown rather than a silent proof, which was dropping DOM handler registrations entirely.

One reported defect is left unfixed and is pre-existing on main: Promise#then/catch/finally omit the invoke-user-code and TypeError that SpeciesConstructor and the IsPromise check imply. Declaring them needs the checker-fact exporter to discharge a synchronous throw across an async boundary the way the summary path already does, which is a separate change; the trustReason now records the gap.

Cost

Production summaries on the external codebase move to 954 unknown / 144 trusted / 319 inferred, against 1,094 / 17 / 299 before this branch and 892 / 175 / 350 with the unsound mechanism in. Roughly 60 summaries and most of the Timer attribution are the price of not publishing a proof that is wrong.

🤖 Generated with Claude Code

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