SFEP-TBD
Signature-Checked Interface Conformance
- Status
- Draft
- Type
- language
- Created
- Updated
- Author
- agent:compiler-architect; human review
SFEP-XXXX — Signature-Checked Interface Conformance
Design record for closing the interface-conformance soundness gap tracked in
docs/status.md(“Interface conformance validation | Partial | Basic checks; variance not enforced”). This is a correctness/soundness fix, not a new feature: today’s name-only conformance check can install a wrong-typed function pointer into an interface vtable slot, which is then called indirectly at the interface’s expected type.
1. Summary
check_struct_implements_interfaces (compiler/src/typecheck_types.sfn:95-134)
currently verifies interface conformance by name only: a struct “implements” an
interface if it has a method for every member name the interface declares. It
never compares parameter types, arity, or return type against the interface’s
declared signature. Because interface method calls lower to an indirect call
through a vtable slot whose function-pointer type is derived from the call
site, not from the installed function (core_call_emission.sfn:558-596), a
struct method with the same name but an incompatible signature is silently
installed into that slot and called through the wrong LLVM function type — a
type-unsound indirect call, not merely a missing diagnostic. This proposal
extends check_struct_implements_interfaces to check arity, parameter types,
and return type for exact compatibility (1.0 has no variance), adds a new
diagnostic family (E0303) distinct from the existing missing-member
diagnostic (E0301), and ships the tightening behind the same
SAILFIN_EFFECT_ENFORCE-style transitional severity knob the effect system
used to migrate the in-tree corpus before flipping to hard-error.
2. Motivation
The bug, precisely
check_struct_implements_interfaces walks each implements annotation,
resolves the interface declaration by name via resolve_interface_annotation
(typecheck_types.sfn:136-149), and for each interface member does exactly
one check:
if !contains_string(method_names, member.name) { diagnostics.push(make_missing_interface_member_diagnostic(statement.name, interface_name, member.name));}method_names is built by pushing statement.methods[_mi].signature.name for
every struct method (:98-104) — names only, no signature data retained. The
three adjacent validators don’t fill the gap either:
validate_interface_annotation(:169-188) checks only generic type-argument arity on theimplements Container<T>annotation itself (does the struct supply the right number of type arguments) — it never looks at method bodies.check_interface_members(:785-808) validates the interface declaration is well-formed (check_function_signature→check_type_parameters, plus a duplicate-member-name check) — it runs over the interface, never cross-checks against any implementing struct.check_struct_methods(called fromtypecheck.sfn:463) validates the struct’s own methods in isolation — again, no cross-check against interfaces it claims to implement.
So today, this compiles with zero diagnostics:
interface Greeter { fn greet(self, name: string) -> string;}
struct Loud { volume: int;}impl Loud { // same name "greet", wrong arity AND wrong param/return types — // accepted today because only the name is checked. fn greet(self) -> int { return self.volume; }}(Sailfin does not have a separate impl block — methods live directly in the
struct body per 03-declarations.md §3.3 — but the shape of the mismatch is
the same wherever fn greet is declared inside struct Loud { ... } with
implements Greeter.)
Why this is a soundness bug, not a missing lint
Interface-typed values lower to a two-word trait object { i8*, i8* } (data
pointer + vtable pointer; rendering_helpers.sfn:31-44). The vtable itself is a
struct of function-pointer-typed slots, one per interface member, populated at
each implements-resolution site by bitcast (<fn-ptr-type> @<method> to <fn-ptr-type>) (rendering_helpers.sfn:72-107) — <fn-ptr-type> there is
whatever the actual struct method’s signature lowers to.
A dynamic dispatch call site (core_call_emission.sfn:507-660, the
trait_dispatch__<Interface>__<method> path) does the reverse: it builds
function_type from the call site’s own operand types
(fp_param_types built from final_operands, :558-567) plus the
interface’s declared llvm_return, then loads the raw i8* from the
vtable slot and bitcasts it to that call-site-derived function type
(:576-596) before calling through it.
If the installed method’s real signature differs from the interface’s declared
signature — different parameter count, different parameter LLVM types
(i64 vs a boxed struct pointer, for instance), or a different return type —
the bitcast reinterprets a function pointer as having a type it does not
actually have. Calling through it is undefined behavior at the LLVM level:
arguments land in the wrong registers/stack slots, the return value is
misinterpreted, and in the aggregate-parameter/return case this manifests as
memory corruption, not merely “the wrong number came back.” This is the same
class of hazard the project already treats as urgent for owned-value handling
(#1205-style aliasing corruption) — an indirect call through a mismatched
function type is a compiler-introduced memory-safety bug, reachable from
ordinary, valid-looking source with no unsafe/extern escape hatch involved.
Who hits it, and why silently
Any interface with more than a trivial single-signature member is at risk the
moment a struct’s method for that name drifts from the interface (a common
refactor mistake: renaming a parameter’s type, adding an optional parameter,
changing -> string to -> string?). There is no compiler feedback — sfn check is green, make compile succeeds, and the failure only surfaces at
runtime as a crash or corrupted data, often far from the actual defect. Per
the project principle “don’t ship unfinished safety claims,” a name-only check
that market-facing docs and the interface’s own type signature imply is a real
contract is actively worse than no check: it teaches users the implements
clause is verified when it is not.
3. Design
3.1 Scope of the check
Extend check_struct_implements_interfaces so that, for each interface
member the struct has a same-named method for, it additionally verifies:
- Arity — the struct method’s
parameterslist has the same length as the interface member’sparameterslist. - Parameter types, pairwise, in order — after substituting the
interface’s type parameters with the concrete arguments from the
implements Container<T>annotation (see §3.3), each pair of parameter type-annotation texts must be exactly equal (no variance — see §3.4). - Return type — same substitution-then-exact-text-equality rule applied
to
return_type(interface member’s, possibly absent/void, vs. the struct method’s). selfreceiver — both signatures’ first parameter, by convention namedself(per03-declarations.md§3.3/§3.5: “the first parameter is bareself”), is a structural fixture, not a type to compare. It is excluded from the parameter-type comparison entirely:selfcarries notype_annotationtoday (bareself, no: Typesuffix — confirmed by every interface/struct example in the spec), so comparing it as a normal parameter would either vacuously pass (both sidesnull) or, if a future syntax allows an explicitself: Treceiver annotation, incorrectly reject valid conformance. The rule: if the first parameter’s name is exactly"self"on both sides, skip it for the type comparison and start the pairwise walk at index 1; require both sides to agree on whether index 0 is present and namedself(a member with aselfreceiver cannot be satisfied by a method without one, and vice versa — that mismatch is reported as an arity/shape mismatch under the same new diagnostic, not silently ignored).
This mirrors the existing exact-match philosophy of check_try_operator’s
?-operator error-type check (E0812, typecheck_types.sfn:420-438): “?
requires an exact match (no From coercion yet)” is the same posture applied
here to interface parameter/return types, for the same reason — this compiler
pass has no expression-type inferencer (#829) and no subtyping/variance
lattice, so anything short of exact text equality (after substitution) is
unimplementable without a much larger prerequisite.
3.2 New function: check_interface_member_signature_match
Add a pure helper next to check_struct_implements_interfaces:
fn check_interface_member_signature_match( struct_name: string, interface_name: string, member: FunctionSignature, method: MethodDeclaration, substitution: TypeSubstitution) -> Diagnostic[]Called from inside the existing _mbi loop in
check_struct_implements_interfaces, only when the name lookup already
found a matching struct method (i.e. it runs alongside, not instead of, the
existing E0301 missing-member check — a member that’s missing by name still
reports E0301 and is not also signature-checked, since there is nothing to
compare against). The loop needs the matched MethodDeclaration, not just the
boolean contains_string result, so check_struct_implements_interfaces
changes its lookup from contains_string(method_names, member.name) to a new
helper find_method_by_name(statement.methods, member.name) -> MethodDeclaration?
that returns the method (or null), used for both the existing missing-member
branch (null case, unchanged E0301) and the new signature check (non-null
case).
check_interface_member_signature_match body, in order:
- Shape/arity check. Compare
self-receiver presence (§3.1 rule) betweenmember.parametersandmethod.signature.parameters. If they disagree, or if the non-selfparameter counts differ, emit oneE0303diagnostic summarizing the arity/shape mismatch (message shows both full rendered signatures — see §3.5) and return early (no point comparing types position-by-position against a misaligned list). - Parameter type check. Walk the non-
selfparameters pairwise. For each interface parameter, applyapply_type_substitution(already exists,typecheck_types.sfn:271-291) withsubstitutionto itstype_annotation.text, then compare the substituted text against the struct method parameter’stype_annotation.textviastrings_equal(string_utils, already imported). On any mismatch push oneE0303for that parameter, naming the parameter, expected substituted type, and actual type; continue checking remaining parameters (report every mismatched parameter in one pass, not just the first — matches the existing convention of returning aDiagnostic[]rather than stopping at first error, as seen throughouttypecheck_types.sfn). - Return type check. Same substitution +
strings_equalcomparison betweenmember.return_typeandmethod.signature.return_type, treatingnull(no declared return type / implicit void) on both sides as a match, and anull/non-nullmismatch as a failure. Emit oneE0303if mismatched.
All three checks are independent (not early-return after step 1’s arity check fails to find a mismatch) — a caller can have both a wrong parameter type and a wrong return type; both are reported in the same pass, consistent with the compiler’s general practice of surfacing all diagnostics per node rather than one-at-a-time.
3.3 Generic interfaces: instantiating the member signature
For interface Container<T> { fn get(self, index: int) -> T?; fn len(self) -> int; } implemented as struct IntList { ... } with implements Container<int>, the member’s declared return type text is T? — this must be
substituted to int? before comparing against the struct’s actual fn get(self, index: int) -> int? method.
check_struct_implements_interfaces already has everything needed to build
this substitution — it is the same shape resolve_enum_variant_field_type
uses (typecheck_types.sfn:299-319):
let provided_arguments = parse_type_arguments(annotation.text); // already computed for arity validationlet substitution = build_type_substitution(interface_definition.type_parameters, provided_arguments);build_type_substitution and apply_type_substitution already exist unchanged
(:225-291) — this proposal adds no new substitution machinery, only a new
call site. Compute substitution once per implements annotation (right
after the existing validate_interface_annotation call, which already
guarantees arity is correct — §3.1’s signature check only runs when arity
validated cleanly, so a substitution with mismatched-length olds/news
never reaches the per-member comparison) and thread it into
check_interface_member_signature_match for every member of that
implements entry.
For a non-generic interface (expected_count == 0), parse_type_arguments
returns [] and build_type_substitution naturally produces an empty
substitution — apply_type_substitution is then a no-op passthrough (no
identifier matches substitution.olds because it’s empty), so the same code
path handles both generic and non-generic interfaces without a branch.
3.4 Compatibility rule: exact match, no variance (1.0 scope)
Per the project’s “fix the foundation first” and “don’t ship unfinished safety
claims” principles, this check does not attempt covariant return types,
contravariant parameters, or any subtyping relaxation — those require a real
type lattice this compiler does not have (types are compared as post-
substitution annotation text, per the existing type_is_result / ?-operator
precedent, typecheck_types.sfn:355-378). Exact textual equality after
substitution is the complete 1.0 rule:
intmatchesint;intdoes not matchint?(nullability is part of the type text and is not unified).T?(substituted toint?) matches only literalint?, notintand notOptional<int>or any other spelling.- Whitespace/formatting differences in the annotation text must not cause a
false mismatch — reuse
trim_text(already used throughout this file, e.g.:184) on both sides beforestrings_equal, sofn get(self, index:int)andfn get(self, index: int)compare equal. - No structural equivalence for compound types beyond substitution —
Array<T>substituted toArray<int>must match a literalArray<int>spelling on the struct method;int[]is a different spelling and does not matchArray<int>even if they denote the same runtime type today. This is a known, documented limitation (type annotations are compared as strings, not as resolved types — the same limitationapply_type_substitution’s own docstring already lives with) and is not a regression this proposal introduces; tightening it further needs the type-annotation resolver work generic constraints will need anyway (§6, cross-reference to0038-generic-constraints.md).
3.5 New diagnostic: E0303
The missing-member diagnostic is E0301
(make_missing_interface_member_diagnostic, :2085-2097); the type-argument-
arity family is E0302 (make_interface_type_argument_mismatch_diagnostic and
its two siblings, :1865-1907). E0303 is the next free code in that family
(confirmed clear — a repo-wide scan of compiler/src finds no existing
E0303 use). One diagnostic factory, parameterized by a kind so the three
sub-cases (§3.2 steps 1–3) share rendering plumbing but produce
distinguishable messages:
// E0303 — struct method signature does not match the interface member's// declared signature (arity, a parameter type, or the return type).fn make_interface_signature_mismatch_diagnostic( struct_name: string, interface_name: string, member_name: string, expected_signature: string, actual_signature: string, detail: string, // e.g. "parameter `index`: expected `int`, got `string`" span: SourceSpan?) -> Diagnostic { return Diagnostic { code: "E0303", severity: "error", message: "struct " + struct_name + " implements " + interface_name + " but method `" + member_name + "` does not match the interface's" + " declared signature: " + detail + ". Expected `" + expected_signature + "`, found `" + actual_signature + "`.", file_path: "", primary: token_from_name(member_name, span), suggestion: null };}expected_signature / actual_signature are the full rendered fn name(params) -> return text (reuse or extend format_interface_signature’s sibling
formatting helpers) so the user sees the whole shape, not just the one
differing token — this matches how E0812’s ?-operator diagnostic names
both full types rather than only the mismatched token. suggestion starts
null (no auto-fix in v1 — the fix depends on developer intent, whether the
struct method or the interface declaration is wrong, mirroring why
make_missing_interface_member_diagnostic also carries no suggestion
today); a fix-it (retype the parameter to match) is a natural fast-follow once
this ships, tracked separately.
3.6 Enforcement severity: transitional flag, mirroring SAILFIN_EFFECT_ENFORCE
This is a backward-incompatible tightening: any struct/interface pair in
an existing capsule that “conforms” today only because names match — with a
divergent parameter or return type — newly fails to compile once this ships
as a hard error. The effect system shipped an identical class of tightening
(Phase D of docs/proposals/0008-effect-validation.md, flipping the default
from warning to error only after an in-tree audit) behind
SAILFIN_EFFECT_ENFORCE; this proposal reuses that exact transitional shape
rather than inventing a new one.
Add SAILFIN_INTERFACE_ENFORCE (a distinct env var — interface conformance
and effect enforcement are unrelated concerns and must not share a toggle),
read via a new interface_gate.sfn, structurally identical to
effect_gate.sfn (compiler/src/effect_gate.sfn:58-83):
fn resolve_interface_enforcement(raw: string) -> string { if raw == "off" { return "off"; } if raw == "warning" { return "warning"; } return "error"; // unset / "" / "error" → default}
fn read_interface_enforcement_env() -> string ![io] { return env.get("SAILFIN_INTERFACE_ENFORCE");}Ship this feature with the default already at “error” for
E0303 specifically — unlike the effect system’s Phase B (which shipped
warning-by-default and flipped later), here the migration path is: (a) land
the checker with E0303 gated by SAILFIN_INTERFACE_ENFORCE, defaulted to
"warning" for one release cycle so the in-tree compiler corpus and any
existing capsules get one alpha cycle of visibility before the check is
fatal, then (b) a fast-follow flips the unset default to "error" once an
audit (mirroring the effect system’s Phase C in-tree audit) confirms
compiler/src/*.sfn itself has no violations. This two-step landing (warn
first release, error next) is the one deliberate divergence from a
same-PR error-default — justified because, unlike a fresh new diagnostic
class the effect system didn’t have before, this specifically replaces a
check that previously reported nothing at all, so the blast radius on
unaudited downstream capsules is unknown at land time. E0301 (missing
member) is unaffected — it stays an unconditional hard error exactly as
today; only the new E0303 signature-mismatch class is gated by
SAILFIN_INTERFACE_ENFORCE during the transition. =off skips only the
E0303 signature comparison (§3.2), never the E0301 name-presence check
or the E0302 arity check — both continue to run unconditionally, matching
how SAILFIN_EFFECT_ENFORCE=off still runs sfn check’s own gate
(effect_gate.sfn:28-34) even while skipping the build-path gate.
Wire the severity into check_struct_implements_interfaces’s call site in
typecheck.sfn the same way validate_and_render_effects_with_capabilities
threads severity onto each EffectViolation before rendering
(effect_gate.sfn:141-148): the new E0303 Diagnostics get their
severity field overwritten by the resolved enforcement level before being
appended to the returned Diagnostic[], so “warning” mode still surfaces the
message (visibility) without failing make compile / sfn check.
3.7 Worked example
interface Container<T> { fn get(self, index: int) -> T?; fn len(self) -> int;}
struct IntList { items: int[];}
// OK: `get` and `len` both match Container<int>'s instantiated signatures.struct GoodIntList { items: int[]; fn get(self, index: int) -> int? { return null; } fn len(self) -> int { return 0; } implements Container<int>;}
// E0303: `get`'s return type is `int`, but Container<int>.get is `int?`.struct BadIntList { items: int[]; fn get(self, index: int) -> int { return 0; } fn len(self) -> int { return 0; } implements Container<int>;}(Field/method/implements ordering above follows the existing
03-declarations.md §3.3 struct-body shape; exact placement of implements
is unaffected by this proposal.)
4. Effect & capability impact
None. check_struct_implements_interfaces and the new
check_interface_member_signature_match are pure functions over already-parsed
Statement/FunctionSignature/MethodDeclaration AST data — no new effectful
operation is introduced anywhere in the typecheck pass itself. The one
![io]-effectful piece, read_interface_enforcement_env (an env.get call,
mirroring read_effect_enforcement_env), is confined to the same build-path
orchestration layer the effect gate already lives in (main.sfn /
tools/check.sfn call sites), not the pure typecheck core. No capability
manifest surface changes: SAILFIN_INTERFACE_ENFORCE is a compiler-internal
transitional knob, not a capsule-declarable capability.
5. Self-hosting impact
Passes touched, in pipeline order:
- AST (
ast.sfn) — no change.FunctionSignature,MethodDeclaration,Parameter,TypeAnnotationall already carry every field this check reads (parameters,return_type,type_annotation.text,name). - Type Checker (
typecheck_types.sfn) — the only pipeline stage that changes:check_struct_implements_interfaces(:95-134) gains the substitution computation (§3.3) and replaces itscontains_stringboolean lookup with afind_method_by_namelookup that also drives the new signature check.- New
find_method_by_name(methods: MethodDeclaration[], name: string) -> MethodDeclaration?helper. - New
check_interface_member_signature_match(§3.2). - New
make_interface_signature_mismatch_diagnostic(§3.5,E0303).
- New file
compiler/src/interface_gate.sfn(§3.6) — theSAILFIN_INTERFACE_ENFORCEread + severity resolution, structurally mirrorseffect_gate.sfn. Wired into the samemain.sfn/tools/check.sfncall sites that currently invokevalidate_and_render_effects*, so the severity-overwrite happens at the same orchestration layer, not insidetypecheck_types.sfnitself (keeping that file’s functions pure, per its existing convention — none of itscheck_*functions read the environment today). - Effect Checker, Native Emitter, LLVM Lowering — no change. This proposal
is purely a rejection of programs the pipeline would otherwise lower
unsoundly; it adds no new IR shape, no new lowering path, and does not touch
emit_native.sfnor any file underllvm/. (The vtable/dispatch mechanism incore_call_emission.sfnandrendering_helpers.sfndescribed in §2 is cited to establish why the check matters — it is not modified by this proposal. A previously-accepted mismatched program simply becomes a diagnostic instead of reaching that lowering code at all.)
Self-hosting invariant. This is additive to the compiler’s own source: the
compiler’s interface/implements usages are checked against the same rule
their consumers are. Before landing, run make compile with
SAILFIN_INTERFACE_ENFORCE=warning and read the emitted warnings — if
compiler/src/*.sfn itself has zero pre-existing signature drifts (expected,
since the compiler was written assuming exact conformance informally), the
warning-mode landing and the later error-mode flip both self-host cleanly with
no compiler-source changes required. If the audit does find drift in
compiler/src/*.sfn, fixing those call sites is a prerequisite sub-step before
the warning-to-error flip (not before the initial warning-mode land) — this
mirrors exactly how the effect system’s Phase C audit (11 debug-trace routines)
preceded Phase D’s default flip (effect_gate.sfn:19-26). Both landing steps
are single-PR, no-seed-cut changes per .claude/rules/seed-dependency.md: the
capability (E0303 check + gate) and its only consumer (the compiler’s own
interface/implements declarations, already present in the pinned seed’s
grammar) ship together — make compile builds the new compiler from the old
seed, and that new compiler self-checks its own implements sites in the same
pass. No new seed is required for either the warning-default landing or the
later error-default flip.
6. Alternatives considered
- Do nothing / leave it a documented “Partial” limitation. Rejected — this
is not a missing convenience feature, it is a reachable memory-safety hazard
(§2).
docs/status.md’s own “Partial” note already flags it; this proposal closes it rather than leaving a soundness hole documented-but-open indefinitely. - Enforce only arity, not full type equality. Rejected — arity-only still
permits the
bitcast-to-wrong-function-type hazard whenever a parameter’s type (not count) diverges (e.g.intvs a boxed struct pointer), which is exactly the case most likely to corrupt memory (aggregate/pointer ABI mismatch) rather than merely misread an integer. - Enforce with structural/variance-aware type equality (covariant returns,
contravariant parameters) instead of exact match. Rejected for 1.0 — the
compiler has no expression-type inferencer or type lattice (#829); building
one is a much larger prerequisite than this fix, and the project’s “fix the
foundation first” principle puts real subtyping behind more basic
primitives (integer types,
Result<T,E>, generic constraints) that don’t yet exist either. Exact-match-after-substitution is implementable today with the substitution machinery that already exists (§3.3) and matches the precedent already set by the?-operator’sE0812exact-match rule. - Fatal from day one, no transitional flag. Rejected — mirrors exactly why
the effect system didn’t do this either (
effect_gate.sfnPhase B/C/D history): an unknown amount of existing capsule code may rely on today’s name-only conformance without ever having hit the runtime hazard, and a same-PR hard break with no visibility window is more disruptive than necessary. The two-step warning-then-error landing gives one alpha cycle of signal. - Fold this into the general effect-enforcement flag
(
SAILFIN_EFFECT_ENFORCE) instead of a new one. Rejected — interface conformance and capability/effect enforcement are unrelated axes (one is a type-soundness check, the other is a capability-security check); sharing a toggle would force an operator who wants to silence one to also silence the other, and would misuse a flag whose name and existing documentation (docs/status.md,0008-effect-validation.md) is specifically about effects. - Runtime (rather than compile-time) signature check — e.g. tag the vtable
slot with a signature hash and check it at dispatch. Rejected — Sailfin’s
differentiator is compile-time capability/type enforcement; a runtime check
adds cost to every dynamic dispatch call for a defect class that is fully
determinable at compile time (all types involved are statically known at
the
implementssite). It would also only convert a memory-corruption bug into a runtime panic, not prevent the unsound program from being accepted at all.
7. Stage1 readiness mapping
- Parses — no parser change required;
implements,interface, and struct-method syntax already parse today. (N/A — this proposal touches no grammar.) - Type-checks / effect-checks —
check_interface_member_signature_matchE0303+find_method_by_name, wired intocheck_struct_implements_interfaces;interface_gate.sfn’s severity threading. Not yet implemented.
- Emits valid
.sfn-asm— N/A; this proposal rejects programs before emission, it adds no new IR shape. - Lowers to LLVM IR — N/A; no lowering change (§5).
- Regression coverage — see §8, not yet written.
- Self-hosts —
make compilewithSAILFIN_INTERFACE_ENFORCE=warningmust succeed with zeroE0303warnings againstcompiler/src/*.sfn’s owninterface/implementssites before the error-mode flip; not yet run. -
sfn fmt --checkclean — applies tointerface_gate.sfnand thetypecheck_types.sfndiff once written. - Documented in
docs/status.md+ spec —docs/status.md’s “Interface conformance validation” row flips from “Partial” to “Shipped” (warning-mode landing keeps it “Partial” with an updated note; only the error-default flip graduates it to “Shipped”, consistent with “parsed but not enforced is not shipped”).graduates-to: reference/spec/06-types.md— interfaces have no existing spec-chapter section yet (03-declarations.md§3.5 currently coversinterfacedeclaration syntax only); this proposal’s graduation adds the conformance rule to06-types.mdas the canonical home for interface type semantics, cross-linked from03-declarations.md§3.5.
8. Test plan
compiler/tests/unit/interface_signature_conformance_test.sfn— direct unit coverage ofcheck_interface_member_signature_match/check_struct_implements_interfaces:- Matching arity + parameter types + return type → no diagnostics.
- Wrong parameter type (non-generic interface) → one
E0303, message names both types. - Wrong return type → one
E0303. - Wrong arity (extra/missing non-
selfparameter) → oneE0303, shape message, no attempted pairwise type comparison. - Missing
selfreceiver on one side only → oneE0303(shape mismatch), distinct from the “both haveself, compare from index 1” path. - Generic interface (
Container<T>/implements Container<int>): member return typeT?substituted toint?matches a realint?method, rejects a realintorstring?method. - Multiple simultaneous mismatches (both a parameter and the return type
wrong) → two
E0303diagnostics in one pass, not one. - Whitespace-only differences (
intvsint) → no false-positive mismatch (trim before compare). - Existing
E0301(missing member) andE0302(arity) behavior unchanged — regression-guard the current passing tests in this file’s neighborhood (typecheck_types.sfn’s existing interface tests, if any, must still pass unmodified).
compiler/tests/integration/interface_enforcement_gate_test.sfn—SAILFIN_INTERFACE_ENFORCEcontract:resolve_interface_enforcement("off" | "warning" | "" | "error")returns the right severity string (mirrorseffect_gate_test.sfn’s existing shape forresolve_effect_enforcement, if present — reuse that test’s structure); awarning-mode run surfaces the diagnostic without a non-zero build exit; anerror-mode (or unset, once flipped) run fails the build;offskips onlyE0303, notE0301/E0302.compiler/tests/e2e/interface_signature_mismatch_test.sfn— an![io]e2e test (per.claude/rules/no-bash-e2e.md) that spawnssfn checkagainst a fixture source string containing the exactLoud/Greeter-shaped mismatch from §2, asserting the captured stderr containsE0303and does not contain a successful build marker; a companion positive-case fixture (matching signatures) asserts a cleansfn checkexit.- Self-host verification —
make compile(first withSAILFIN_INTERFACE_ENFORCE=warningto confirm zero in-tree violations surface as warnings, then a fullmake checkonce the checker lands) per.claude/rules/selfhost-invariant.md.
9. References
docs/status.md— “Interface conformance validation | Partial | Basic checks; variance not enforced” (the row this proposal closes); the Diagnostics section describing the shareddiagnostics_render.sfnrenderer andE05xx/E06xxfamilies used as an env-var/severity precedent.docs/proposals/0008-effect-validation.md— theSAILFIN_EFFECT_ENFORCEPhase B/C/D transitional-severity precedent this proposal’sSAILFIN_INTERFACE_ENFORCEmirrors structurally.compiler/src/effect_gate.sfn— the concrete file structure (resolve_*_enforcement,read_*_enforcement_env, severity-overwrite wiring)interface_gate.sfnis modeled on line-for-line.compiler/src/typecheck_types.sfn:95-134(check_struct_implements_interfaces),:136-149(resolve_interface_annotation),:169-188(validate_interface_annotation),:225-291(TypeSubstitution/build_type_substitution/apply_type_substitution— reused unchanged),:355-438(the?-operatortype_is_result/E0810–E0812exact-match precedent),:785-808(check_interface_members),:1865-1907(theE0302arity-diagnostic family),:2085-2097(make_missing_interface_member_diagnostic,E0301).compiler/src/llvm/expression_lowering/native/core_call_emission.sfn:507-660— thetrait_dispatch__<Interface>__<method>vtable dispatch lowering that makes this a soundness bug, not a missing lint (call-site-derivedfunction_typebitcast onto the loaded vtable slot).compiler/src/llvm/rendering_helpers.sfn:31-107— trait-object layout ({ i8*, i8* }), vtable type definitions, and vtable-constant population (bitcast (<fn-ptr-type> @<method> to <fn-ptr-type>)).site/src/content/docs/docs/reference/spec/03-declarations.md§3.3, §3.5 — current documentedstruct/interface/implementssyntax (bareselfreceiver convention this proposal’s §3.1 rule depends on).0038-generic-constraints.md(forthcoming SFEP) — constraint satisfaction for generic bounds (fn foo<T: Container>(...)) will need to answer “doesTsatisfyContainer” using the same conformance check this proposal hardens; today’s name-only check would let an ill-typedTsatisfy a bound it does not actually meet, silently propagating this same vtable hazard into generic-constraint-gated code. This proposal is a direct prerequisite.draft-derive.md(forthcoming SFEP) — derived interface implementations (e.g. a future#[derive(Serializable)]-style mechanism) must produce methods that pass this same signature check; deriving is only sound if the generated method’s signature is verified against the interface exactly like a hand-written one, so this proposal’s checker is the gate derive-generated impls run through, not a parallel bespoke validation path..claude/rules/seed-dependency.md— the bundle-not-split reasoning in §5.