Absence is merged, not defaulted

Superseded in part by a-field-is-a-flag-and-a-type. The deletion of map-pattern defaults stands, and so does the reason for it. What does not is the migration spelling: a record literal is now a put over one base, so [tier: 'a', ...$given, ...$dflt] no longer parses. Write the put — [...$dflt, ...] with the given fields written out, or a block, whose merge is application — and over a record whose fields are not known where the merge is written, absence still travels as a variant.

Superseded again by a-table-never-enters-the-unifier: the literal is an update, not a put. The spread comes first and adds no field, so [...$dflt, k: v] replaces fields $dflt has and is refused for a k it lacks.

A missing field is filled by merging in a record that supplies it, not by attaching a fallback expression to the pattern that reads it. Two changes land as one decision: a bracketed literal’s entries settle its kind, so a two-record merge written with one explicit field ([tier: 'a', ...$given, ...$dflt]) is a record and not a list; and map-pattern defaults ([port: p = 8080]) are deleted, because with the merge expressible a defaulted field is a second, worse mechanism for exactly what spread shadowing already does at the level of data.

Context

row-types already argued that scoped labels and spread shadowing are how ral expresses optionality and defaults structurally — see its “Scoped labels and spread shadowing are how ral expresses optionality and defaults at the level of data” — but that route was unreachable for the one case that matters here: a merge of two records. [...$given, ...$dflt], with nothing but spreads, has no entry to settle whether it is a record or a list, and falls to list — silently, since a list of records is not a type error until something indexes it. Reaching for a default inside the pattern instead was the only way to say “fall back to this value” without that literal.

A map-pattern default was also the one place pattern matching could cause an effect. IrPattern’s only Comp was a defaulted field’s fallback expression, and that single fact was the reason several traversals existed at all: elaborator.rs had to elaborate a pattern before its own names entered scope so a default resolved outward rather than to itself; ir::walk_pattern_defaults was a dedicated free-name walk over patterns, otherwise unwalked because a pattern’s own names are bound, never referenced; typecheck::annotate::annotate_pattern existed only to carry the checker’s verdict onto a default’s Comp, a pure clone everywhere else; and typecheck::infer::bind_pattern’s Map arm shaped the inferred row around which entries had a default and which did not. It also sat uneasily beside fixed-arity’s “no variadic application and no defaulted parameter” — a defaulted pattern field was the one place that rule did not reach.

Decision

A bracketed literal’s keys settle its kind, and one explicit field is enough to settle it. [tier: 'a', ...$given, ...$dflt] is a record merge: the field says the literal is a record before either spread is read, and record-spread’s existing priority rule — an explicit entry beats every spread, and of two spreads the first wins on a conflict — does the rest, giving $given its fields back and falling through to $dflt for whatever it omits. A literal of spreads alone stays a list; a merge that has no field of its own to write is one the reader must anchor (records-and-maps).

Map-pattern defaults are deleted. MapPatternEntry loses its default field; the parser no longer reads an = after a map-pattern entry’s sub-pattern; every traversal that existed only to reach a default is deleted outright rather than left running over an always-None field: elab_pattern’s default arm, ir::walk_pattern_defaults and its four call sites, annotate_pattern (now a plain clone at each of its former call sites), and free_refs::collect_default_free_refs. bind_pattern’s Map arm in the checker now extends the inferred row with every entry unconditionally — a pattern field is always required, exactly as fixed-arity already requires of an argument. evaluator::pattern::stage_pattern no longer runs a nested machine to evaluate a fallback, so it sheds the mooring and shell parameters it held only for that; bind_pattern/bind_pattern_staged still take shell (their observe callback reaches it independently of pattern matching) but drop mooring, which nothing in the pattern module needs any more. A pattern absent a field is a plain runtime error, key '…' not found, exactly as it already was for a required field.

Migration

let [host: h, port: p = 8080] = $cfg

becomes

let [host: h, port: p] = [tier: 'a', ...$cfg, ...[port: 8080]]

An explicit entry always wins over a spread regardless of position, so the fallback cannot be spelled as a bare port: 8080 beside $cfg’s spread — it would win unconditionally instead of only on absence. Spelling the default as a second spread keeps it subject to the same first-spread-wins rule as $cfg’s own fields.

This migration holds where $cfg’s own fields are known where the merge is written. Where they are not — $cfg a row-polymorphic parameter — no spelling of a default works, a map-pattern default no more than a second spread; that limit is the row algebra’s, not this decision’s, and is recorded in an-open-spread-must-come-last.

A soundness hole leaves with it

The removed implementation had a live gap worth recording, not as justification but as a fact about what is gone: a defaulted field’s inferred type was unified with neither its default expression’s type nor a present field’s actual type, so let [flag: flag = 1] = [:] typechecked and then failed at runtime with a type mismatch, and an ill-typed default such as !{floor true} escaped checking entirely — it was only ever forced if the field was absent at run time, never checked against the field’s inferred type. Merging plain records has no such gap: every field in [tier: 'a', ...$given, ...$dflt] is checked at the type the record literal already gives it.

That last sentence held only for known records, and at the time it was written a second hole hid the difference: a literal with two or more spreads discarded both rows for a free row variable, so the merge appeared to typecheck over an open $given too. Closing it showed that neither mechanism can default a field of a record whose fields are unknown — so this decision’s replacement claim was too broad, while the deletion it argues for stands on its own.

See also

row-types (the merge idiom this decision leans on), fixed-arity (“no variadic application and no defaulted parameter” — a map-pattern default was the one place that did not hold), optionality-via-variants (the other half of “optionality is data, not a hole in a signature”), docs/SPEC.md §4.5, §5.2, §14.