ADR 0100: Open-World Diagnostic Policy for Unresolved Selectors and Classes

Status

Proposed (2026-06-27)

Implementation status (2026-07-10): Rule 1 (BT-2793) and Rule 3 (BT-2793) are implemented. Rule 2 was split out to BT-2794 and remains unimplemented, blocked on BT-2795 (WS1: project-wide extension visibility) and BT-2796 (WS2: project-wide class hierarchy) actually landing and being verified complete — see the Rule 2 section and its "Sequencing guard" below. Status stays Proposed rather than Accepted until Rule 2 also lands.

Context

Problem statement

Beamtalk is a dynamic, open-world language: any object may implement doesNotUnderstand:, selectors can be sent reflectively via perform:, classes can be extended at runtime (ADR 0066), and methods can be hot-reloaded while the system runs. In such a world a statically unresolved message send is not proof of a bug — the receiver may understand the message at runtime through a mechanism the checker cannot see.

At the same time, the type checker can often prove that a send will fail (a concrete, closed receiver type whose whole method surface is known and does not include the selector). Reporting nothing in that case wastes a real signal — typos, renamed methods, and wrong receivers are exactly the errors a Smalltalker expects the tooling to catch.

The tension is therefore not "should we report unresolved sends?" but "with what severity, given how complete the checker's knowledge is?" That question has never been written down. The behaviour exists in code, was assembled incrementally, and includes at least one workaround (suppressing diagnostics whenever a receiver has a cross-file parent) that is really a stand-in for "my static knowledge is incomplete here." The project-scoped compilation epic (BT-2251) is about to make that knowledge complete for the intra-project case, which forces the question: when the checker becomes more certain, does it become more severe? This ADR fixes the policy so that WS1/WS2 (project scope) and a future strict mode have a principled rule to build against, rather than each escalating severity ad hoc.

Current state (as of this ADR)

Severity and category infrastructure already exists (crates/beamtalk-core/src/source_analysis/parser/mod.rs):

The de-facto behaviour for an unresolved selector (type_checker/validation.rs, type_checker/inference.rs):

Receiver situationToday
Unresolved selector on a known, concrete classHint (Dnu)
Receiver is Dynamic / unknownSilent (no diagnostic)
Union where no member responds and there is no uncertaintyWarning
Union, some members respond or membership is uncertainHint
Class declares a doesNotUnderstand: overrideSilent
Class has a cross-file parentSilent (incompleteness workaround)

Unresolved classes and FFI targets emit Warning (UnresolvedClass / UnresolvedFfi). @expect dnu / @expect type suppress at a site (ADR 0077). --warnings-as-errors promotes Warning/Hint to Error, but excludes UnresolvedClass, UnresolvedFfi, ArityMismatch, and Deprecation for gradual migration; Dnu is not excluded, so today --warnings-as-errors already makes unresolved selectors fatal.

Constraints

Decision

Severity is a function of how complete the checker's knowledge is, and escalation to a build-failing error is always opt-in — never the default.

Concretely, three rules:

Rule 1 — Knowledge gates severity (the "completeness ladder")

Implemented (BT-2793): the classifier lives in crates/beamtalk-core/src/semantic_analysis/receiver_knowledge.rs (ReceiverKnowledge / classify_receiver), consulted from the four sites that previously re-derived the decision independently: type_checker/validation.rs::check_class_side_send and check_instance_selector, and protocol_registry.rs::check_conformance_to_protocol and check_class_side_conformance. This was a pure refactor of where the decision lives — behaviour is unchanged; the has_cross_file_parent suppression stays until Rule 2 (below) removes it.

Diagnose an unresolved selector only as severely as the checker's certainty warrants. Absence of evidence is not evidence of absence in an open world.

Checker's knowledge of the receiverDefault severity
Unknown / Dynamic — cannot enumerate the method surfaceSilent
Open — known class but with a doesNotUnderstand: handler, or an open extension point the checker can't closeSilent
Closed & complete — concrete known class; full method surface known across the project and dependency interfaces; no DNU handler; not DynamicHint (Dnu)
Provably failing union — every member is closed and none responds, no uncertaintyWarning (Dnu)
Unresolved class / FFI target — the name resolves to nothingWarning (UnresolvedClass / UnresolvedFfi)

The default ceiling for a send the checker believes will fail is Hint — or Warning only when failure is provable (the union case). This mirrors Dialyzer's success-typing stance: complain only when a call can be shown to fail, stay quiet otherwise.

Classification is conservative — when in doubt, classify down. A receiver is ClosedComplete only when the checker can enumerate its entire method surface with certainty. The following force a downgrade to Open (→ silent), so they are never misreported:

Because severity hinges entirely on this classification, the Dynamic/Open/ClosedComplete decision MUST be made in one shared place so validation.rs and inference.rs cannot disagree about the same call site (see Implementation).

Rule 2 — Completeness improves accuracy, it does not raise severity

Not implemented — split to BT-2794, blocked on BT-2795 (WS1) and BT-2796 (WS2). BT-2251 ("Epic: project-scoped compilation & type-checking") is marked Done in Linear but shipped only this ADR and the ADR 0070 amendment document (PR #2788), not the WS1/WS2 code — the precondition below does not yet hold. Implementing Rule 2 before it does would, per the "Sequencing guard" a few paragraphs down, "turn every previously-suppressed site into a fresh false positive." BT-2793 implements Rule 1 and Rule 3 only, which have no WS1/WS2 dependency, and keeps the has_cross_file_parent suppression this rule describes removing.

When project-scoped compilation (BT-2251 WS1/WS2) gives the checker complete intra-project knowledge, it replaces the incompleteness workarounds with real resolution — but the default severity is unchanged. Specifically:

This is the crux: project scope buys precision, not strictness. Strictness is a separate, explicit choice (Rule 3).

Sequencing guard (important). The incompleteness workarounds may be removed only once the knowledge that made them necessary actually exists — removing them early turns every previously-suppressed site into a fresh false positive. The binding order:

  1. The cross-file-parent suppression stays until project-wide hierarchy assembly (WS2) is verified complete.
  2. A receiver whose class may carry cross-package extensions stays classified Open (Rule 1) until cross-package extension metadata is loaded (WS3 / the ADR 0070 amendment). Until then, a fully-known intra-project surface is not sufficient to call such a receiver ClosedComplete, or the checker would emit a Hint for a dependency-contributed method that genuinely exists.

In other words: suppression is removed per-receiver only when the checker can prove its surface is complete for that receiver, not globally when "WS1 has landed." A feature flag gates the removal until the corresponding workstream is verified, so a partially-landed epic never regresses diagnostics.

Rule 3 — Escalation is opt-in and per-category

Implemented (BT-2793): the [diagnostics] table in beamtalk.toml, parsed in crates/beamtalk-cli/src/commands/manifest.rs (DiagnosticsTable / DiagnosticSeverityOverride) and applied in crates/beamtalk-cli/src/beam_compiler.rs (apply_diagnostics_table, ahead of the --warnings-as-errors promotion pass — precedence steps 2 and 3 below). apply_diagnostics_table enforces a severity floor: it never touches a diagnostic that already carries Severity::Error (e.g. ActorNew, Inheritance, EmptyBody — hard structural errors, not Rule 1 completeness-ladder output), so the table can only move the soft diagnostics this rule is about, never silence a guaranteed compile error. See the Package Management guide ([diagnostics] Section) for the user-facing schema, the severity floor, and examples.

Promotion of soft diagnostics to build-failing Errors is never the default. It is requested explicitly:

Precedence (so the sources never silently conflict). A Dnu diagnostic's final disposition is resolved most-specific-wins, in this order:

  1. Site-level @expect dnu / @expect type (ADR 0077) — always wins; an explicit acknowledgement at the call site silences it regardless of any global setting. (A site that resolves after WS1/WS2 makes its @expect stale; the existing stale-directive warning — BT-1412, queries/diagnostic_provider.rs — already flags an @expect with no matching diagnostic, so improved resolution self-reports the now-dead annotation with no new mechanism.)
  2. Per-category project table (beamtalk.toml, future) — sets the category's base severity for the package (ignore / hint / warn / error).
  3. Rule 1 default — the completeness-ladder severity, when nothing above applies.

--warnings-as-errors is a final promotion pass, not a competing base severity: after 1–3 resolve a diagnostic to Warning/Hint, the flag promotes it to Error (minus the gradual-migration exclusions). So a dnu = "ignore" table entry removes the diagnostic before the flag ever sees it, while dnu = "warn" plus --warnings-as-errors yields an error (the beamtalk.toml keys here are illustrative — the actual schema is deferred, per Rule 3). This keeps the flag's meaning ("treat what remains as fatal") intact and orthogonal to the base policy.

What a user sees

REPL — a typo on a closed receiver is a soft, navigable hint, and the expression still runs (the runtime raises the real DNU if reached):

"hello" reverssed
// hint: 'String' does not appear to understand #reverssed (did you mean #reversed?)
// => the expression still compiles and runs; runtime raises doesNotUnderstand: if evaluated

Dynamic receiver — silence, because the checker cannot know:

x := someApi fetchThing.   // x : Dynamic
x wobble                   // no diagnostic — open world, may be understood at runtime

Provable failure on a closed union — a Warning, the strongest soft signal:

n := flag ifTrue: [1] ifFalse: [2.0].   // n : Integer | Float
n frobnicate
// warning: no member of 'Integer | Float' understands #frobnicate

Opt-in strict build — the same hint, now fatal, by explicit request:

$ beamtalk build --warnings-as-errors
error: 'String' does not appear to understand #reverssed   (Dnu, promoted)

Prior Art

What we adopt: Dialyzer's provability bar (Rule 1) and TypeScript's opt-in escalation (Rule 3). What we reject: Gleam's closed-world fatality as a default.

User Impact

Discoverability

The did you mean note rides the existing hint/notes fields on Diagnostic, so editors and the REPL surface the suggestion without new infrastructure. The @expect directive (ADR 0077) is the discoverable, in-source way to mark a send as intentionally dynamic.

Steelman Analysis

Option B — Escalate to Warning when statically complete

This is the strongest competitor, and the tension is real — honestly, a complete closed receiver is exactly as provable as the union case that already warns. The distinction is not provability (claiming otherwise would be inconsistent: both rest on "every candidate class is closed and none responds"). The distinction is blast radius and frequency:

So the union Warning is retained as an existing, narrow aggressive case, and the common single-receiver case stays Hint — with escalation available per-category (Rule 3) for teams that want union-level strictness everywhere. If post-WS1 false-positive rates prove low, raising the closed-complete ceiling is a one-line change to that table, not a re-decision of this ADR.

Option C — Hard error when statically complete (Gleam-style)

Rejected as a default: it breaks the language's open-world guarantee and the live-augmented model (a file that fails static checking in isolation may resolve against the running image). The legitimate piece of this — sealed Value classes with no DNU handler are a genuinely closed sub-world — is precisely the kind of narrow rule Rule 3's opt-in table can encode later, not a global default.

Tension points

Alternatives Considered

Promote unresolved selectors to Warning/Error by default

Covered as Options B/C above — rejected as defaults, preserved as opt-in (Rule 3).

Keep the cross-file-parent suppression permanently

Rejected. It is an incompleteness workaround, not a policy: it silences real typos whenever a class happens to have an out-of-file parent. Project scope (WS2) removes the incompleteness, so the workaround should go with it (Rule 2), not calcify into the contract.

Route Dnu to the Lint channel instead of Hint

A genuine option: emit unresolved-selector diagnostics at Severity::Lint (invisible in a normal beamtalk build, surfaced only by beamtalk lint) rather than Hint (always shown as an informational note). Lint gives a stronger opt-in separation than Hint — the default build is completely silent on unresolved sends. Rejected as the default because the typo-catching value of the hint is highest in the editor, inline, as you type (LSP surfaces Hints; Lint implies a separate command run), and Smalltalkers expect the tool to point at a suspicious send immediately, not on a deferred lint pass. Lint remains available as a per-category choice under Rule 3 for teams who want unresolved sends out of the default build entirely.

One global strictness flag only (no categories)

Rejected. UnresolvedClass/UnresolvedFfi/ArityMismatch already need independent migration treatment (they are excluded from --warnings-as-errors today). A category-aware policy is already a de-facto requirement; this ADR names it rather than inventing it.

Consequences

Positive

Negative

Neutral

Implementation

This ADR is policy; the code changes ride the BT-2251 workstreams.

Migration Path

This ADR mostly codifies existing behaviour, so most code needs no change. Two transitions warrant care:

References