SFEP-0078
Struct Field Default Values
- Status
- Accepted
- Type
- language
- Created
- Updated
- Author
- agent:compiler-architect (drafted); project owner (direction + decisions)
- Tracking
- SFN-392 (Phase 1), SFN-1281 (Phase 2)
SFEP-0078 — Struct Field Default Values
1. Summary
A struct field may declare a default value — body_addr: int = 0; — using the
same = <expr> form the language already ships for default parameters. A
struct literal that omits a field with a default materializes that default;
a literal that omits a field without a default becomes a typecheck error
(E0311) instead of today’s lowering-stage E1002. The default’s value comes
from the field’s declaration, written by the type’s author, and never from the
compiler fabricating a zero — so this closes the additive-field breaking-change
problem without reopening the SFN-527/SFN-392 zero-fill soundness hole, which
stays closed by construction.
2. Motivation
2.1 There is no way to add a field to a public struct
capsules/sfn/http/src/types.sfn:21 struct Response gained two fields —
body_addr: int and body_len: int at types.sfn:58-59, added in commit
98b99fa so a response can carry binary bodies a NUL-terminated string
cannot hold. Every hand-written Response { status, headers, body } literal in
out-of-tree Sailfin code stopped building the moment that landed.
The capsule’s own source records the consequence as a warning to readers
(capsules/sfn/http/src/types.sfn:36-41):
Every
Responseliteral must state all five fields. A partial literal typechecks and then fails at LLVM lowering withE1002(“refusing to fabricate a default”), so these two fields are a breaking change to every hand-written literal — set both to 0 for a text response.
and the published reference repeats it as a Breaking change admonition
(site/src/content/docs/docs/reference/standard-library.md:1020). Both
documents are honest, and both document a language limitation rather than a
library decision: Sailfin offers its authors no additive way to extend a public
struct. Every field added to any exported struct, in any capsule, forever, is a
major-version break for every literal of it.
The specific new fields make the point sharply. body_addr: 0 and
body_len: 0 are exactly what a text-body caller wants, exactly what every
in-tree builder (response, html_response, json_response, not_found,
redirect) already passes, and exactly what the type’s author would have
written as the default if the language had a place to write it. The break is
pure ceremony: it forces thousands of call sites to restate a constant the
author already knows.
2.2 The status quo: check green, build red, four stages disagreeing
The partial-literal path is not merely unsupported — it is inconsistently unsupported across the pipeline. Reproduced against seed 0.12.0 on this branch:
$ sfn check partial.sfnchecked 1 files: ok # rc 0
$ sfn build partial.sfnllvm lowering [fatal] [E1002]: struct literal for `T` missing field `b` — refusing to fabricate a default `i64` # rc 1Each stage handles the literal differently, and the disagreement is structural:
| Stage | What it does with a partial literal | Where |
|---|---|---|
| Parse | Stores only the fields written, in source order, with no back-reference to the declaration | Expression.Struct / ObjectField at compiler/capsules/syntax/src/ast.sfn:96 and :248-251; built by parse_struct_literal at compiler/capsules/syntax/src/parser/expressions/literals.sfn:309-324 |
| Typecheck | Iterates the literal’s fields, never the declaration’s — so a missing field is not merely unreported, it is unreachable | compiler/capsules/analyzer/src/typecheck/expression_walk.sfn:325-354 |
| Emit | Re-serializes the partial set verbatim into .sfn-asm |
compiler/capsules/codegen/src/emitter_sailfin_expr.sfn:106-120 |
| Lower | Inverts the iteration to the declared list and searches the literal per declared field; on a miss raises fatal E1002 while still emitting the insertvalue with default_return_literal so the IR stays structurally valid |
compiler/capsules/codegen-llvm/src/expression_lowering/native/core_literals_lowering.sfn:743-746 (declared iteration), :747-756 (per-field search), :838-856 (the E1002 branch); default_return_literal at compiler/capsules/codegen-llvm/src/expressions_helpers.sfn:153-161 |
The lowering gate is pinned by
compiler/tests/e2e/lowering_fabricated_value_gate_test.sfn:112-114.
So the language’s only answer to “this field is missing” lives in the backend,
four stages downstream of where the author wrote the literal, with no span in
the user’s source file naming the literal, and it is invisible to sfn check —
rung 1 of the validation ladder. That is the #1389 class of build-only failure,
in a construct that is common, not exotic.
2.3 The symmetric feature already ships end-to-end
Default parameter values are Shipped (docs/status.md:1626, documented at
site/src/content/docs/docs/reference/spec/03-declarations.md:66). Verified by
running it: fn add(a: int, b: int = 7) called as add(1) exits 8. The full
chain exists and is the template this proposal follows:
| Link | Site |
|---|---|
Parse = <expr> after the annotation |
compiler/capsules/syntax/src/parser/declarations/syntax.sfn:564-581 |
| AST slot | Parameter.default_value: Expression?, compiler/capsules/syntax/src/ast.sfn:239 |
.sfn-asm emit |
compiler/capsules/codegen/src/emit_native.sfn:827-828, compiler/capsules/codegen/src/emit_native_format.sfn:605-606 |
| Native-IR slot | NativeParameter.default_value: string?, compiler/capsules/ir/src/native_ir.sfn:79 |
.sfn-asm re-parse |
compiler/capsules/ir/src/native_ir_utils_parse.sfn:745-782 |
| Cross-module arity contract | compiler/src/typecheck_import_loader.sfn:157-171 — “NativeParameter.default_value round-trips through the artifact … so a staged signature carries its defaults faithfully” |
Render back to .sfn-asm |
compiler/capsules/codegen-llvm/src/rendering_helpers.sfn:266-277 |
| Call-site fill | compiler/capsules/codegen-llvm/src/expression_lowering/native/core_call_lowering.sfn:502-528 |
FieldDeclaration and NativeStructField are the same shape as their
parameter counterparts minus the default slot —
compiler/capsules/syntax/src/ast.sfn:272-277 and
compiler/capsules/ir/src/native_ir.sfn:133-137. There are exactly two
FieldDeclaration { … } construction sites
(compiler/capsules/syntax/src/parser/declarations/structs.sfn:223-228 and
compiler/capsules/syntax/src/parser/declarations/enums.sfn:235-240), and
roughly a dozen non-test files reference the type.
2.4 SFN-392 declared this out of scope because it was undocumented
SFN-392 (“reject struct literals that omit required fields”) put, verbatim, under Out:
Introducing a field-default / partial-literal language feature. None is documented, and the lowerer’s own diagnostic shows the original intent was to reject.
That was the correct call for a soundness fix, and its reasoning names exactly the gap this proposal fills: none is documented. This SFEP is that document. It does not weaken SFN-392 — §3.2 shows it strengthens it, by moving the rejection forward from the backend to typecheck and leaving the backend gate in place as a backstop.
3. Design
3.1 Syntax
A field declaration gains an optional = <expr> tail, positioned exactly where
a parameter’s default sits:
struct Response { status: int; headers: string[]; body: string; body_addr: int = 0; body_len: int = 0;}
// Legal: the two defaulted fields materialize as 0.let r = Response { status: 200, headers: [], body: "hi" };
// Legal: an explicit value always wins over the default.let b = Response { status: 200, headers: [], body: "", body_addr: p, body_len: n };
// Error E0311: `status` has no default and was omitted.let bad = Response { headers: [], body: "hi" };Grammar (site/src/content/docs/docs/reference/grammar.md:65):
FieldDeclaration = [ "mut" ] Identifier TypeAnnotation [ "=" Expression ] ";" ;which is character-for-character the tail already in the Parameter
production two dozen lines below it (grammar.md:111):
Parameter = [ "mut" ] Identifier [ TypeAnnotation ] [ "=" Expression ] ;This is not a new concept, and should not be argued as one. CLAUDE.md’s
“boring syntax wins” test asks whether a deviation buys expressiveness: here
the alternative spellings (@default(0), ?field: int, a Default interface)
all introduce a second way to say “this named thing has a value when
unmentioned” while the language already has one. Rust, Swift, Kotlin, Python
and TypeScript all spell the struct/class-member default = expr; an LLM
generating .sfn from zero training data will write = 0 and be right.
The field terminator rules are unchanged: ;, ,, or nothing for the last
field (compiler/capsules/syntax/src/parser/declarations/structs.sfn:200-221).
The default expression ends at the terminator.
3.2 Semantics
- A field with a default is optional in a literal. Omitting it materializes the declared default at that literal site.
- A field without a default is required. Omitting it is
E0311at typecheck, reported at the literal’s span, listing every missing field at once rather than one per pass. - An explicit field always wins. The default is consulted only when the name is absent from the literal.
- Defaults are materialized per literal site, not evaluated once. There is no shared “default instance”; each omission lowers its own constant. With §3.3’s constant restriction this distinction is unobservable today, and stating it now prevents a future extension from inheriting Python’s mutable default-argument trap.
- Declaration order, layout, size, alignment, and ABI are unchanged. A
default is a source-level convenience attached to a field; it does not
reorder fields, does not permit reordering in a literal (that is already
allowed — lowering searches by name at
core_literals_lowering.sfn:747-756), and does not changeNativeStructLayoutField(compiler/capsules/ir/src/native_ir.sfn:139-145). - A defaulted field is still a field. It is
mutor not on its own terms, it is matched, read, and assigned identically, and it appears in the layout exactly where it was declared.
Why this does not reopen SFN-527/SFN-392. The hole those issues closed was
that the compiler invented a value: default_return_literal(llvm_type)
(compiler/capsules/codegen-llvm/src/expressions_helpers.sfn:153-161) returns
0/false/null/zeroinitializer derived from the field’s LLVM type,
with no relationship to the author’s intent. SFN-392’s reproducer is the exact
failure: a flag: boolean the author meant to set to computed became
i1 false and the computed SSA value went dead. Nothing in the program said
false was correct; the backend picked it because i1’s zero is false.
A declared default is the opposite: the value is written by the type’s author, in the type’s own declaration, in source the reader can see, and it type-checks against the field’s declared type like any other expression. The distinction is provenance, and the design keeps it structural rather than relying on discipline:
- The
E1002gate atcore_literals_lowering.sfn:838-856stays, unchanged in wording and severity. It gains one branch before it: if the declared field carries a default, lower the default; otherwise fall through toE1002. default_return_literalis never reached for a defaulted field. The two paths never meet: a default either exists (author’s value) or does not (E1002, refuse).- With
E0311in typecheck (§3.4),E1002becomes the backstop it was designed to be rather than the primary diagnostic — it fires only when a literal reaches lowering that the frontend could not adjudicate (an imported struct with no import context, §3.4), which is precisely the fail-closed case it exists for.
3.3 What may be a default: constant expressions only
Recommended: restrict Phase-1 defaults to self-contained constant expressions. Concretely, the accepted forms are:
- integer, float, string, and boolean literals;
null;- a unary
-applied to a numeric literal.
Everything else — identifiers, calls, field access, arithmetic, array literals,
struct literals, enum constructors — is rejected at typecheck with E0312.
The backend does not force this restriction, and the proposal should not
claim it does. Verified: the parameter-default path passes its default straight
to lower_expression
(core_call_lowering.sfn:510-512), whose first parameter is
expression: string — native-IR expression text
(compiler/capsules/codegen-llvm/src/expression_lowering/native/core_expression.sfn:89).
Field defaults can reuse that machinery verbatim. So the restriction is a
frontend policy choice, made for three reasons:
- Scope capture.
lower_expressiontakes the enclosingbindingsandlocals. A field default is lowered at the literal’s site, not the declaration’s, so an identifier in a default would resolve against whatever locals happen to be in scope where someone else wrote the literal — silently different per call site, and catastrophic when a name collides. A constant expression has no free names, so the implementation passes emptybindings/localsand the hazard is unrepresentable rather than avoided. - Evaluation order and effects. An arbitrary-expression default raises
three questions this proposal would otherwise have to answer: when is it
evaluated (declaration time? literal time? per omission?), in what order
relative to the explicitly-written fields, and may it carry an effect —
can
created_at: int = clock.now()appear in a struct declaration, and if so does every literal of that struct acquire![clock]? That last question is the serious one: it would make a struct declaration an effect-bearing construct, which nothing in the effect system currently models (see §4). Constants make all three questions vacuous. - Cross-module honesty. The default crosses a capsule boundary as text in
.sfn-asm(§3.5). A constant’s text means the same thing in the consumer’s module as in the producer’s. An expression’s text does not — it can name producer-module symbols the consumer never imported.
The cost is real and should be stated: headers: string[] = [] — the single
most-wanted default on Response — is not expressible under this
restriction, because an empty array literal is a heap allocation rather than an
LLVM constant. That is a deliberate Phase-4 follow-on (§5.4), not an oversight;
it needs a decision about whether each omission allocates a fresh empty array
(the answer should be yes, per semantic 4) and a check that the allocation is
emitted into the literal’s own lowering, not hoisted.
Const-foldable arithmetic (2 * 1024) is likewise excluded from Phase 1:
it requires a const-evaluator the frontend does not have, and the workaround
(write the folded value) is one keystroke. null is included, because it is
a first-class literal for every nullable annotation and is the natural default
for an added T? field — the single most common additive change after a scalar.
3.4 Where the required-field check lives: E0311 in typecheck
The missing-required-field error moves to
compiler/capsules/analyzer/src/typecheck/expression_walk.sfn:325-354, the
Struct branch of walk_expression, which today iterates only
expression.fields (the literal’s) and so can never observe an absence.
The declaration is already reachable from there. _struct_field_expected_type
(compiler/capsules/analyzer/src/typecheck/struct_literal_fn_values.sfn:130-152)
walks ctx.declarations for a StructDeclaration of the literal’s type and
returns the named field’s annotation; _imported_struct_field_expected_type
(:164-186) does the same over ctx.imported_structs. The new check needs the
inverse lookup — the declared field list, not one field’s type — so the
implementation adds a sibling _struct_declared_fields(type_name, ctx) -> FieldDeclaration[]? in the same file, returning null when the shape is
unresolvable and the declared list otherwise (local first, imported second,
matching the existing precedence).
Diagnostic shape:
E0311: struct literal for `Response` is missing required field `status` --> app.sfn:42:13 = note: fields `status` and `body` have no default and must be given = help: add `status: <value>, body: <value>`, or declare a default on the field: `status: int = 0;`One diagnostic per literal listing every missing field, not one per field — a literal of a struct that grew three fields should not produce three errors.
Two suppression conditions, both already precedented in this function.
- Unresolvable shape.
expression_walk.sfn:320-324already computesno_import_context && shape_unresolvable && name_is_importedto defer the fn-value verdict (SFN-1161) when the literal names an imported type whose declaration is not loaded — the statesfn checkis in for a bare file with no built artifacts.E0311takes the same deferral: if_struct_declared_fieldsreturnsnull, emit nothing and let the literal reachE1002at lowering, which has the fullcombined_structsset (compiler/capsules/codegen-llvm/src/lowering/lowering_core/mod.sfn:255). This is whyE1002must stay. - Generic structs. The existing
is_generic_structguard in the same loop suppresses element-type checking because.sfn-asmcarries no struct type parameters. Field presence is independent of type substitution, soE0311does not need that guard — but the implementer must confirm the declared field list for a generic struct is complete rather than erased before relying on it.
Field-value type checking. The default expression is walked at the
declaration with ctx_with_expected_type(ctx, field.type_annotation), so an
ill-typed default (count: int = "x") is caught by the existing mismatch
diagnostics (E0309/E0310 family) rather than a new code.
E-code allocation. E03xx — duplicate symbols / type conflicts, home
typecheck*.sfn (docs/style-guide.md:221-228) — is the right range: a
literal whose field set does not match its declaration is a type-shape
conflict, sibling to E0310 (array element type mismatch). A repo-wide
rg -o --no-filename '[EW][0-9]{4}' compiler runtime docs site capsules shows
E0301–E0310 in use and nothing above, so this proposal allocates:
| Code | Meaning | Emitted at |
|---|---|---|
E0311 |
struct literal omits a field that has no default | typecheck/expression_walk.sfn (Struct branch) |
E0312 |
struct field default is not a constant expression | typecheck_types/declaration_and_statement_checks.sfn::check_struct_fields (:42-62) |
The docs/style-guide.md row lands with the implementing PR; the reservation
is recorded in docs/proposals/README.md so other drafts skip these two.
3.5 Cross-module carry
A default declared in sfn/http must reach a consumer capsule’s lowering,
exactly as a parameter default does today (§2.3). Five sites, all mirroring the
parameter chain:
| # | Change | Site |
|---|---|---|
| 1 | FieldDeclaration.default_value: Expression? |
compiler/capsules/syntax/src/ast.sfn:272-277; set at the two construction sites parser/declarations/structs.sfn:223-228 and parser/declarations/enums.sfn:235-240 (the latter always null in Phase 1, §3.7) |
| 2 | NativeStructField.default_value: string? |
compiler/capsules/ir/src/native_ir.sfn:133-137 |
| 3 | .sfn-asm write — append = <expr> to the struct field line |
compiler/capsules/codegen/src/emit_native_format.sfn:632-637 (format_field), mirroring :605-606 for parameters |
| 4 | .sfn-asm read |
compiler/capsules/ir/src/native_ir_utils_parse.sfn:595-633 (parse_struct_field_line) |
| 5 | Artifact → frontend AST reconstruction, so E0311 knows an imported field is defaulted |
compiler/capsules/analyzer/src/typecheck_imports.sfn:178-196 (field_declaration_from_native / field_declarations_from_native) |
Site 4 is the one with a trap. parse_struct_field_line currently strips an
optional mut prefix and then splits on the first ": ", assigning
everything after it to type_annotation
(native_ir_utils_parse.sfn:604-629). Appended naively, body_addr: int = 0
parses as a field of type "int = 0". The fix is the parameter parser’s
discipline (:753-772): after the type separator, split the remainder on the
first =, left side is the annotation, right side is the default text. That is
safe in both directions — a type annotation can never contain =, and only the
first = is consumed, so a default whose text contains == survives intact.
The legacy -> separator branch (:604-620) needs the same treatment.
The default deliberately rides the .struct field line, not the .layout
line. NativeStructLayoutField is parsed from a keyed-token line
(.layout field <name> type=<T> offset=N size=N align=N) whose type value
absorbs unrecognized trailing tokens by design — _nir_layout_type_may_continue
exists because “type annotations may contain whitespace” (SFN-532,
compiler/capsules/ir/src/native_ir_utils_layout.sfn:188-192). Adding a
default= token there would be parsed as part of the type on any older reader
and is a worse round-trip risk for zero benefit.
Site 4 → lowering. StructFieldInfo
(compiler/capsules/codegen-llvm/src/types.sfn:317-323) gains
default_value: string?. It is built in build_type_context from
layout.fields (compiler/capsules/codegen-llvm/src/type_context.sfn:204-232),
which has no defaults — but the same loop holds definition, the NativeStruct
whose fields: NativeStructField[] does. The implementation looks the default
up by name from definition.fields while walking layout.fields. Imported
structs arrive through the same path: combined_structs is the module’s own
structs extended with imported_structs
(lowering/lowering_core/mod.sfn:255), and build_type_context takes
NativeStruct[] (type_context.sfn:178), so a dependency’s declared defaults
are present with no additional plumbing.
3.6 The lowering branch
core_literals_lowering.sfn:838-856 — the else of the
literal_index >= 0 test — becomes:
if the declared field carries a default: lower the default text with EMPTY bindings and EMPTY locals, expected_type = expected.llvm_type on success: use that operand for the insertvalue on failure: push fatal E1002 (the default itself did not lower)otherwise: push fatal E1002 as today ← unchanged, including wordingEmpty bindings/locals is the mechanical enforcement of §3.3’s scope-capture
argument: even if a non-constant default somehow reached the backend, it cannot
silently bind to a local at the literal’s site — it fails to lower and reports
E1002. The string-constant accumulation and temp_index/lines threading
follow core_call_lowering.sfn:510-518 exactly.
3.7 sfn fmt, the Sailfin source emitter, and enum-variant fields
sfn fmt almost certainly needs no printer change, and the reason is worth
recording because it contradicts the obvious assumption. sfn fmt is not an
AST pretty-printer: format_source lexes the source and re-emits a classified
token stream (compiler/src/tools/fmt/mod.sfn:14-35). There is no
parameter-default printer in compiler/src/tools/fmt/ to mirror — grep finds no
default_value reference under that directory at all — because a parameter’s
= 7 flows through as an operator token and an operand token. A field’s = 0
will do the same. The one heuristic that inspects struct bodies classifies them
by the presence of colons and semicolons to refuse inlining
(compiler/src/tools/fmt/emitter.sfn:42-48); an added = does not perturb it.
This is a prediction, not a verified fact, and the implementer must prove it:
the Phase-2 acceptance includes a sfn fmt --write / --check round-trip over
a fixture carrying defaults in single-line, multi-line, mut, and trailing-
comma field forms. If fmt does mangle the spacing, that is a fmt bug to fix in
emitter.sfn, not a reason to change the syntax.
The AST-driven Sailfin emitter is a different matter and does need the
print: format_field at
compiler/capsules/codegen/src/emitter_sailfin.sfn:559-565 must gain the
= <expr> tail its sibling format_parameter already has at :599-600.
Enum-variant payload fields. parse_enum_variant_field constructs the same
FieldDeclaration (parser/declarations/enums.sfn:235-240), and
EnumVariantInfo.fields is a StructFieldInfo[]
(compiler/capsules/codegen-llvm/src/types.sfn:339, populated at
type_context.sfn:259), so the machinery would carry variant defaults for
nearly free.
Recommendation: do not enable them in Phase 1. The enum-variant literal path
is currently unsound in the opposite direction — SFN-913 (Backlog, High,
type:bug/area:lowering) records that an enum-variant literal with a missing
field emits no store at all, leaving the payload slot holding whatever the
alloca contained, with the advisory at
core_literals_lowering.sfn:1309 carrying no [fatal] marker so nothing trips
on it. Layering an opt-in “omission is legal” feature onto a path where omission
is already silently accepted and reads back stack residue would make the
unsound case indistinguishable from the intended one. SFN-913 is a
predecessor of enum-variant defaults, not a parallel concern: once it lands,
the enum path has the same fail-closed gate as the struct path and the default
branch drops in identically (§5.4, Phase 5).
In Phase 1 the enum variant-field parser is left unchanged, so = <expr> there
fails to parse through the existing variant-field failure path. That is honest
but produces a poor message; see §10 for the open question of whether to spend a
third E-code on a targeted one.
3.8 What does not change
- Struct layout, field order, size, alignment, and the boxed-
%T*ABI. E1002’s wording, severity, and the e2e gate atcompiler/tests/e2e/lowering_fabricated_value_gate_test.sfn:112-114. Its fixture struct has no defaults, so it stays green verbatim.default_return_literal(expressions_helpers.sfn:153-161) — unchanged, and still unreachable from any defaulted field.- Extra/unknown fields in a literal. Rejecting those is a separate gap
(
E1012’s neighbourhood atcore_literals_lowering.sfn:1326handles the enum case) and is deliberately out of scope. - The
mutaxis. A default says nothing about mutability, and interacts with SFEP-0075’s mutability floor only in that a defaultedlet-bound struct is still immutable.
4. Effect & capability impact
None, and that is a design constraint rather than an observation.
The constant-expression restriction (§3.3) is what makes it none. A constant carries no effect, so a struct declaration remains an effect-free construct and a struct literal’s effect set remains exactly the union of its explicitly written field expressions — unchanged from today. No function signature gains an effect because a struct it constructs grew a default.
The alternative is worth naming so a future extension does not walk into it
accidentally: an arbitrary-expression default such as
created_at: int = clock.now() would make the struct declaration an
effect-bearing construct, and every literal of that struct that omitted the
field would acquire ![clock] transitively — a capability that appears in a
caller’s signature because of a field it never mentioned. That inverts the
effect system’s core promise, which is that reach is derived from what the code
does and readable at the site that does it. Any future proposal to widen §3.3
beyond constants must answer this before anything else; the capability manifest
(SFEP-0016) is derived from exactly these edges.
Capsule manifests, E0402/E0403, and the seal are untouched.
5. Self-hosting impact
5.1 The compiler cannot use this feature until a seed carries it
compiler/src/ and compiler/capsules/ are compiled by the pinned seed
during the first pass of sfn dev bootstrap build. A field default written in
compiler source would be a parse error to any seed predating the capability.
Compiler source must therefore not use field defaults until a release
carrying Phase 2 has been cut and bootstrap.toml [seed].version advanced to
it. The feature is additive for everything else — an old seed compiling code
with no defaults behaves identically — but it is not additive for the compiler’s
own sources, and that is the one hard sequencing rule in this proposal.
This is a deferral, not a seed-blocker in the
.claude/rules/seed-dependency.md sense: no phase below requires the capability
to be present in the pinned seed in order to land.
5.2 The sfn/http consumer bundles — there is no seed gate
Verified against .claude/rules/seed-dependency.md’s runtime carve-out:
capsules/sfn/httpis a workspace member (workspace.toml:42-43), not aruntime/capsule.tomlsfn-sourcesentry — the runtime’s declarative source list issfn/*plusprelude.sfn. The carve-out (runtime source calling a capability the seed lacks) therefore does not apply.- The compiler does not depend on
sfn/http:compiler/capsule.toml’s[dependencies]listssfn/syntax,sfn/ir,sfn/analyzer,sfn/codegen,sfn/codegen-llvm,sfn/runtime-native,sfn/cli,sfn/strings,sfn/crypto— nosfn/http. The CLI reaches HTTP throughextern fnprecisely to avoid that edge (compiler/src/cli/commands/cmd_shared.sfn:149, SFN-496). So the pinned seed never compilessfn/httpduring bootstrap. sfn/httpis exercised by e2e tests run under the freshly builtbuild/bin/sfn(compiler/tests/e2e/serve_loopback_test.sfn,http_capsule_serve_gate_test.sfn, and siblings).
Therefore sfn dev bootstrap build builds the new compiler from the old seed,
and that fresh compiler compiles the Response defaults in the same pass.
Capability and consumer bundle into one PR; splitting them would manufacture a
seed cut for a single consumer, which
.claude/rules/seed-dependency.md says not to do.
5.3 Phasing
Three phases to ship the feature, two follow-ons. Each phase is a valid self-hosting compiler.
Phase 1 — E0311 in typecheck. No syntax change. Adds
_struct_declared_fields and the missing-field check with its two suppression
conditions. Ships value alone: it closes SFN-392’s primary scope, moves a
build-only failure to rung 1 of the validation ladder, and does not depend on
anything below.
Fallout risk is low but must be measured, not assumed. Because E1002
already fails the build closed, any first-party struct literal that is actually
built cannot be omitting a field today — so the in-tree fallout should be
near zero. The residual risk is a literal on a path that is checked but never
lowered. sfn check compiler/src (~5 min, rung 1) measures it before
sfn dev bootstrap build is spent. SFN-392’s warning to “budget for first-party
fallout” was written before E1002 was made fatal and is, on current main,
likely over-cautious — but verify rather than assume.
Stage1 items: type-checks, regression coverage, self-hosts, fmt --check,
spec note that omission is an error. No parse/emit/lower change.
Phase 2 — the capability, bundled with the sfn/http consumer (§5.2).
Parse, AST slot, E0312 const-form check, default type-check against the field
annotation, .sfn-asm emit + re-parse, typecheck_imports reconstruction,
StructFieldInfo.default_value, the lowering branch, emitter_sailfin
format_field, fmt round-trip proof, and in the same PR: Response’s
body_addr: int = 0; body_len: int = 0;, the retraction of the warning comment
at capsules/sfn/http/src/types.sfn:36-41, and the Breaking change
admonition at standard-library.md:1020.
Stage1 items: all eight. This is the phase that must clear the full bar,
including sfn dev verify — it touches parse, typecheck, emit, IR, and
lowering, and it is a structural change requiring
sfn dev bootstrap build --clean-tree.
Whether Phase 2 is one issue or two is a decomposition call the grooming pass
should make with .claude/rules/seed-dependency.md in hand. The recommendation
here is one issue: the sfn/http change is three lines plus doc retractions
and has no independent value, and the frontend/backend halves cannot be split
either — a parse-and-carry PR that does not lower is “parsed but not enforced,”
which CLAUDE.md says is not shipped.
Phase 3 — seed advance, then compiler adoption (optional). After a release
carrying Phase 2 and a bootstrap.toml seed bump (automatic per
cadence-seed-pin.yml), compiler source may itself use defaults. Nothing
requires this; it is listed so §5.1’s constraint has a visible endpoint. Good
first candidates are the compiler’s own wide structs where a field was appended
and every literal had to be touched — SourceSpan? slots on Statement
variants are the canonical case (SFN-536’s fallout, which is what surfaced
SFN-913).
5.4 Follow-ons, explicitly not in this proposal’s shipping scope
- Phase 4 — non-scalar constant defaults, starting with the empty array
literal
[](§3.3). Needs a decision on per-site allocation and a lowering path that emits the allocation into the literal’s own block. - Phase 5 — enum-variant payload field defaults, gated on SFN-913 (§3.7).
5.5 Files affected, by pipeline stage
| Stage | Files |
|---|---|
| Parse / AST | compiler/capsules/syntax/src/ast.sfn:272-277; compiler/capsules/syntax/src/parser/declarations/structs.sfn:200-235 |
| Typecheck | compiler/capsules/analyzer/src/typecheck/expression_walk.sfn:325-354; compiler/capsules/analyzer/src/typecheck/struct_literal_fn_values.sfn (new _struct_declared_fields); compiler/capsules/analyzer/src/typecheck_types/declaration_and_statement_checks.sfn:42-62 (E0312); compiler/capsules/analyzer/src/typecheck_imports.sfn:178-196 |
Emit (.sfn-asm) |
compiler/capsules/codegen/src/emit_native_format.sfn:632-637; compiler/capsules/codegen/src/emitter_sailfin.sfn:559-565 |
| Native IR | compiler/capsules/ir/src/native_ir.sfn:133-137; compiler/capsules/ir/src/native_ir_utils_parse.sfn:595-633 |
| LLVM lowering | compiler/capsules/codegen-llvm/src/types.sfn:317-323; compiler/capsules/codegen-llvm/src/type_context.sfn:204-232; compiler/capsules/codegen-llvm/src/expression_lowering/native/core_literals_lowering.sfn:838-856 |
| Consumer | capsules/sfn/http/src/types.sfn:21-59 |
| Docs | site/src/content/docs/docs/reference/spec/03-declarations.md §3.3; site/src/content/docs/docs/reference/grammar.md:65; site/src/content/docs/docs/reference/standard-library.md:1020; docs/style-guide.md E-code table |
6. Alternatives considered
(a) Zero-fill omitted fields. Make the lowering behaviour official: an
omitted field gets default_return_literal(llvm_type). Rejected — this is
the SFN-527/SFN-392 soundness hole, and SFN-392’s reproducer shows exactly why:
a boolean the author meant to compute became i1 false, the computed value
went dead, and the program printed the wrong answer with sfn check green and
the build green. The distinction this proposal turns on (§3.2) is that a value
must have an author. Zero-fill has none.
(b) Spread / functional update — Response { ..base, status: 404 }.
A genuinely good feature that Sailfin should probably have, and it complements
rather than replaces this one: it solves “derive a value from an existing
value”, which is a different problem from “write a value from scratch”. It does
not help the motivating case at all — a handler constructing a fresh Response
has no base to spread, and inventing a Response.empty() to spread from is
just a builder (option c) with extra syntax. It also depends on struct value
semantics being settled (SFEP-0075), which is still Draft. Worth its own SFEP;
not a substitute for this one.
(c) Sealed / #[non_exhaustive]-style builder-only structs. Mark a struct
as non-literal-constructible from outside its defining capsule, forcing
consumers through builders; then adding a field is never breaking because no
external literal exists. This is Rust’s actual answer, and it is a coherent
one — but it is a harder break than the status quo, not a softer one: adopting
it on Response would invalidate every existing literal immediately and
permanently, including the correct five-field ones. It also imposes a builder
API on every struct author for a problem that a default solves declaratively.
Recorded as possible future work, orthogonal to this proposal: a sealed
struct could still carry field defaults for its own builders’ use.
(d) Do nothing — accept that adding a public field is breaking. This is
Rust’s position for non-#[non_exhaustive] structs and it is defensible; the
honest cost is: every stdlib struct is frozen at 1.0, or every addition is a
major version. For a language whose pitch is a machine-checked contract about
what code can reach and what it costs, freezing Response — the type every HTTP
handler in the ecosystem returns — the first time it needs a field is a bad
trade. It also leaves the check-green/build-red divergence of §2.2 in place,
which is a defect regardless of the feature decision. Phase 1 fixes that
divergence and is worth landing even if Phases 2+ are rejected.
(e) @default(0) decorator instead of = 0. Decorators exist
(SFEP-0023), so this needs no grammar change. Rejected on CLAUDE.md’s
boring-syntax test: it is a second spelling for a concept the language already
spells = expr, it reads worse, it is unfamiliar to every language a user
arrives from, and an LLM with no .sfn training data will write = 0 anyway.
(f) Make every field optional, defaulting to the type’s zero. The TypeScript-ish “all fields optional” shape. Rejected — it is (a) with a nicer name, and it additionally destroys the ability to say “this field is required”, which is most of what a struct declaration is for.
7. Stage1 readiness mapping
Phase 1 (E0311 only):
- Parses — n/a, no syntax change
- Type-checks / effect-checks — the deliverable
- Emits valid
.sfn-asm— unchanged - Lowers to LLVM IR — unchanged (
E1002retained) - Regression coverage
- Self-hosts
-
sfn fmt --checkclean - Documented in the spec chapter (03-declarations §3.3)
Phase 2 (the capability + sfn/http):
- Parses —
= <expr>in a struct field declaration - Type-checks / effect-checks —
E0312const form; default type-checked against the field annotation; effect impact is none by construction (§4) - Emits valid
.sfn-asm— field line carries= <expr>, round-trips - Lowers to LLVM IR — defaulted omission materializes the author’s value
- Regression coverage — §8
- Self-hosts —
sfn dev bootstrap build --clean-tree(structural) -
sfn fmt --checkclean — including the round-trip proof of §3.7 - Documented in the spec chapter + grammar + standard-library retraction
docs/status.md is reconciled separately by /status-sweep.
8. Test plan
Unit — typecheck (Phase 1):
compiler/tests/unit/typecheck_struct_literal_missing_field_test.sfn
E0311on a literal omitting one required field, with the literal’s span.- One diagnostic listing both names when two are omitted, not two diagnostics.
- Clean on a complete literal, and on a literal whose fields are written out of declaration order.
- Silent (deferred to
E1002) when the struct shape is unresolvable and the name is imported — theexpression_walk.sfn:320-324condition.
Unit — typecheck (Phase 2):
compiler/tests/unit/typecheck_struct_field_default_test.sfn
- No
E0311when the omitted field declares a default. E0312onx: int = some_fn(), onx: int = other_field, and onx: int = 2 * 1024.- No
E0312on each accepted constant form, includingnullfor aT?field and-1for anintfield. - Existing mismatch diagnostic on
count: int = "x".
Unit — IR round-trip (Phase 2):
compiler/tests/unit/native_ir_struct_field_default_test.sfn
parse_struct_field_line("body_addr: int = 0")yieldstype_annotation == "int"anddefault_value == "0"— the §3.5 trap.- The same for a
mutprefix, for the legacy->separator, for a string default containing a space, and for a field with no default (default_valuestaysnull). - Emit → parse round-trip preserves the default text exactly.
Integration — lowering (Phase 2):
compiler/tests/integration/lowering_struct_field_default_test.sfn
- The
insertvaluefor an omitted defaulted field carries the declared constant, notdefault_return_literal’s zero — assert on the emitted IR, using a default whose value differs from the type’s zero (flag: boolean = true,n: int = 7) so the two are distinguishable. - An explicit value overrides the default.
E1002still fires, unchanged, for a field with no default.
E2E — behaviour and cross-module (Phase 2):
compiler/tests/e2e/struct_field_default_test.sfn
- Build and run a program whose struct has a
true-valued boolean default and a non-zero int default; assert the observed values. - Cross-capsule: a program importing a struct with defaults from another capsule omits a defaulted field and gets the declared value — the §3.5 artifact path, which no unit test exercises.
sfn checkandsfn buildagree on a partial literal in both directions (rejected together for a non-defaulted field; accepted together for a defaulted one). Sibling tocompiler/tests/e2e/check_build_agree_module_global_test.sfn.
Formatter (Phase 2): sfn fmt --write then --check round-trip over a
fixture carrying defaults in single-line, multi-line, mut, and trailing-comma
field forms (§3.7).
Unchanged, must stay green:
compiler/tests/e2e/lowering_fabricated_value_gate_test.sfn:112-114 and the
sfn/http serve suite (serve_loopback_test.sfn, serve_tls_loopback_test.sfn,
http_capsule_serve_gate_test.sfn, tls_large_body_test.sfn).
Verification commands:
sfn check compiler/src # rung 1, Phase 1 fallout scanbuild/bin/sfn test compiler/tests/unit/typecheck_struct_literal_missing_field_test.sfnbuild/bin/sfn test compiler/tests/unit/typecheck_struct_field_default_test.sfnbuild/bin/sfn test compiler/tests/unit/native_ir_struct_field_default_test.sfnbuild/bin/sfn test compiler/tests/integration/lowering_struct_field_default_test.sfnbuild/bin/sfn test compiler/tests/e2e/struct_field_default_test.sfnbuild/bin/sfn test compiler/tests/e2e/lowering_fabricated_value_gate_test.sfnsfn dev bootstrap build --clean-tree # structural (Phase 2)sfn fmt --check <touched files>sfn dev verify # Phase 2 ship gate only9. References
- SFN-392 — reject struct literals that omit required fields (the
soundness half; its Out: section is quoted in §2.4).
Backlog, High. - SFN-527 — fail closed where a null operand is replaced by a default; the
issue behind
E1002and the gate atcore_literals_lowering.sfn:838-856. - SFN-913 — enum-variant literal with a missing field leaves the payload
slot uninitialized. Predecessor of §5.4 Phase 5.
Backlog, High. - SFN-1161 — imported-struct literal fn-value deferral; the precedent for
E0311’s unresolvable-shape suppression (expression_walk.sfn:320-324). - SFEP-0075 — Struct Value Semantics and the Mutability Floor
(
docs/proposals/0075-struct-value-semantics.md). Independent of this proposal; itsE0919–E0921allocation is in a different range. - SFEP-0041 — expected-type context in the typecheck walk; the mechanism that type-checks a default against its field annotation.
- SFEP-0019 —
sfn/http, the motivating consumer. - SFEP-0039 — nominal object model; why a struct literal is the sanctioned bare-object-literal target.
.claude/rules/seed-dependency.md— the bundle-vs-split call applied in §5.2.- Spec:
site/src/content/docs/docs/reference/spec/03-declarations.md§3.2 (default parameters,:66) and §3.3 (structs);site/src/content/docs/docs/reference/grammar.md:65,111. - Prior art: Rust
#[derive(Default)]+..Default::default()(chose the spread form and the sealed form, not field defaults); C++20 default member initializers; Swift default property values; Kotlin/Python/TypeScript default parameters and property initializers — all spelled= expr.
10. Design gate — decisions
The gate was passed 2026-09-11. The four questions this section carried as open were each settled in favour of the recommendation the proposal already made; they are recorded here as decisions so an implementer reads a resolved document, not an open one.
-
Enum-variant payload field defaults are out of scope, and the parse message stays as it is for the interval. Phase 1 leaves
parse_enum_variant_field(compiler/capsules/syntax/src/parser/declarations/enums.sfn:235) unchanged, so a default written on a variant payload field dies in the generic variant-field parse failure with a message that does not say why. That is accepted rather than spending a third E-code on a diagnostic Phase 5 would delete. Enum-variant defaults are gated on SFN-913: omission on that path currently emits no store and reads stack residue, and layering an opt-in “omission is legal” feature onto it would make the unsound case indistinguishable from the intended one. Fix the unsoundness first, then extend. -
nullis an accepted default form in Phase 1. An addedT?field is the most common additive change after a scalar, and excluding it would leave the most-wanted case unserved to preserve a distinction that is already structural. It is the one accepted form whose materialized value coincides with whatdefault_return_literalwould have fabricated — harmless, because provenance still differs (the author wrote it, in the declaration, where a reader can see it) andE1002still fires when no default is declared. -
Generic structs:
E0311does not take theis_generic_structguard, since field presence is independent of type substitution. This is an implementer verification rather than a design fork: confirm the declared field list is complete rather than erased for imported generics inctx.declarationsandctx.imported_structsbefore relying on it, and record the finding on the Phase 1 issue. If it is erased there,E0311suppresses for imported generics andE1002remains the backstop — a narrower check, not a wrong one. -
Phase 1 lands independently of Phase 2. It fixes the
check-green / build-red divergence on its own terms, depends on nothing below it, and moves a build-only failure to rung 1 of the validation ladder. The cost is acknowledged: between the two phases the additive-field problem is louder — a friendlier error, still an error — which is a better position than a backend E-code and a strictly temporary one.
Two constraints that are not open questions and must survive implementation unchanged, restated here because they are the two places where a reasonable shortcut would reintroduce the hole this proposal is careful not to reopen:
- The
E1002gate atcompiler/capsules/codegen-llvm/src/expression_lowering/native/core_literals_lowering.sfn:838-856keeps its wording and its severity. It gains one branch before it. It is not softened, not downgraded to a warning, and not deleted onceE0311exists — it is the backstop for a literal the frontend could not adjudicate. - Defaults lower with empty
bindingsandlocals. The constant restriction of §3.3 is enforced by construction rather than by a check that could be relaxed later without anyone noticing what it was protecting.
11. Implementation tracking
| Phase | Issue | Scope |
|---|---|---|
| 1 | SFN-392 | E0311 in typecheck. No syntax change. Reopened from Duplicate; the lowering-side fix it was closed against covered only half its scope. |
| 2 | SFN-1281 | The capability — parse, AST, E0312, .sfn-asm emit and re-parse, cross-module carry, the lowering branch — bundled with the sfn/http Response consumer and the two doc retractions. |
Phases 3–5 (compiler self-adoption after a seed advance, non-scalar constant
defaults, enum-variant payload defaults) are follow-ons per §5.3 and §5.4 and
are deliberately unfiled: none clears the bar in
.claude/rules/follow-up-filing.md today, and each wants the evidence the phase
before it produces.