28 min read 5730 words Updated Sep 29, 2026 Created Sep 29, 2026

Syntax Design

Status: active design (2026-09-04)

This note is the record of the research behind blr's surface syntax: what
other designers wrote, the principles distilled from it, surveys of specific
constructs (quote, sum labels), and every decision point with its rationale.
The design is the "whatever is easiest to implement" era re-examined against
those principles.

1. Research: what other designers wrote

SourceKey ideasWhat blr adopts
Bjarne Stroustrup, The Design of A Computer Language (CACM 56(9), 2013)A language must grow but stay coherent; a small set of core concepts should compose; the language is a tool for thinking; don't burden the user with what the compiler can do.Orthogonality: few forms, freely nestable (P8).
Thompson & Pike, The Go Programming Language (2015), preface"The language is small, so it is easy to learn and the grammar is simple and consistent." Go's grammar is teachable in an hour.A grammar a newcomer can hold in their head (P1, P4).
Rob Pike, Go Proverbs (Gopherfest 2015)"Clear is better than clever." "gofmt's style is no one's favorite, yet gofmt is everyone's favorite."Favor the boring, standard spelling over the clever short one (P1, P4). Mandate a formatter; syntax formattable without type info (P12).
Milner, Tofte & Harper, The Definition of Standard ML (1997), §1 design rationaleML was designed to be "expressive, elegant, simple, consistent"; the surface syntax should have no "clutter or noise".Minimal keywords in favor of identifiers (P5); one spelling per concept (P2).
PEP 20, The Zen of Python"There should be one — and preferably only one — obvious way to do it."No alternate spellings for the same construct (P2, P3).
Python docs, Design and History FAQ ("Why does Python use indentation for grouping of statements?")Grouping delimiters must be unambiguous — the reader (and editor) must never have to guess which END closes which BEGIN. Whitespace-based grouping trades symbols for fragile line-structure dependence.Open/close pairs for all blocks (P6); nothing hidden in whitespace (P7).
K&R, The C Programming Language (1978/1988)C is a small language with a minimal grammar; familiar C constructs are what decades of readers already know.The "familiar" punctuation set is the C-family vocabulary (P1, P4).
Stroustrup, The Design of the C++ Programming Language (1994)New features must be justified against existing ones; consistency beats cleverness.Every new syntax feature must be justified in the decision log (§7).
Ousterhout, A Philosophy of Software Design (2018)Cognitive complexity: the number of distinct concepts a reader must hold at once; the principle of uniformity — do things one way.Every token is a concept to remember; the token vocabulary has a cognitive budget (P3, P4).
Bob Nystrom, How to Design Good APIs and Frame Rule-Based Decisions (2024)Consistency, discoverability, predictability: "make the obvious choice the right one".Syntax should be self-describing: shape determines meaning (P2, P7).
The Rust Programming Language (ch. 1) & The Rust ReferenceDesign goal of predictability; the Reference publishes the full grammar as the single source of truth.Publish a full EBNF grammar in-repo; no hidden rules (P12).
Zig language docs ("Design", "Language Reference")"explicit is better than implicit"; no hidden control flow, no magic.P7; nothing may depend on position or context.
Ruby community, Principle of Least Astonishment (POLA)The language should not surprise a competent programmer coming from mainstream languages.Operationalizes P1: when in doubt, spell it the way C/JS/Rust/Python/OCaml already do.
Cognitive load theory (Sweller, 1988 et seq.)Working memory holds a handful of items; unfamiliar or inconsistent encoding forces readers to spend it on decoding instead of understanding.The reason "familiar + consistent" beats "concise" (P1, P2).

Cross-cutting observation: every surviving language (C, Go, Rust, OCaml,
Haskell, Python) made the same trade-off — decode cost beats writing cost.
Syntax is optimized for a reader who may never have seen the language, not
for the fastest keystrokes. Blr makes the same trade.

2. Principles

P1 — Familiar

Blr code should be decodable by someone who has never read blr docs, using
only the conventions of C, JS, Rust, OCaml, and Python. New spellings are
allowed only for constructs with no mainstream precedent, modeled on the
closest precedent. POLA test: show the snippet to a programmer from another
language; if they guess the meaning wrong, the syntax is wrong.

P2 — Consistent (isomorphic)

The type of a value and the value of that type should be written the same
way; the same mechanism in the same position everywhere; one spelling per
concept. Target:

ConceptType spellingValue/definition spelling
functionfn (x: T) -> Ufn (x) -> x · fn f(x: T) -> U { ... }
record{x: f64}{x: 1.0}
list[]T (nested: [][]f64)[v, w]
sum[ A unit, B P ]'T::A(v) / 'A(v) · match t { T::A(_) => 1 }

P3 — One meaning per token

Every token means exactly one thing, everywhere; meaning must not depend on
position or the type checker. A token may be reused only if (a) the two uses
are in positions that cannot occur together, or (b) mainstream languages
already reuse it that way (e.g. ( for grouping and call).

P4 — Minimal symbols

The punctuation budget is the C-family set:
{ } ( ) [ ] , ; . : " / + - * = > ! | ' #. A new punctuation character
requires a decision-log entry proving no allowed alternative works. Prefer
multi-character familiar sequences (->, ::, ==) over exotic singles.
No Unicode punctuation, no confusable glyphs.

P5 — Minimal keywords

A keyword is an identifier the user may not name. Use a keyword only when
it informs the parser or the type checker
. Decisions: true/false
demoted to built-in identifiers; match kept as keyword for P1 reasons
(overriding P5, recorded). No keyword is needed for sum types: [ A unit, B P ] is unambiguous by position (type position, comma-separated case
pairs) — the rejected sum keyword is recorded in the decision log.

P6 — Open/close pairs for every block

Every syntactic group is enclosed in a matched pair. A pair's role is fixed
by the form that introduces it: bare { ... } is a block or record;
match { ... } pins arms, # { ... } pins a quote block. In type
position [ ... ] is a sum type (comma-separated case pairs) while the
prefix []T is a list type — decided by the token after [. No construct may end implicitly.

P7 — No hidden syntax

Nothing about meaning may depend on whitespace, line breaks, file position,
or a previous declaration. No context-sensitive tokens; no grammar "mode"
that changes mid-file.

P8 — Orthogonal composition

The same small set of expression forms works at every position and depth.
Prefer rewriting a special form into the general one.

P9 — Frequency-based explicitness

Frequent constructs get the shortest form; rare constructs the most
explicit. Quote is extremely common → one-character prefix. Sum types
are rare → their spelling may be less compact than a common one (they
share [ ... ] with list literals and rely on position), but common
forms never get longer to pay for it.

P10 — Uniform punctuation placement

One convention per punctuation role, documented in the grammar: , between
siblings (trailing allowed everywhere, required nowhere); ; statement
separator; : only for name : type and record fields; -> only for
function results.

P11 — Naming conventions (lint-enforced, not grammar)

One lexical class of identifiers; case carries meaning only by documented
convention + lint. PascalCase for types and sum case labels; snake_case
for values/functions/modules/fields; 'a for type variables.

Considered and rejected: Haskell-style case-as-lexemes (Varid/Conid
split at the lexer). Haskell is essentially the only mainstream language
that does it, it is a persistent source of confusion, and blr has a cleaner
mechanism — labels are distinguished from variables by path qualification,
not case (§5). Recorded in the decision log.

P12 — Toolable and documented

Full EBNF grammar in-repo as the single source of truth; highlighting,
formatting, and match-jump work on incomplete type-less code; every syntax
error names the construct and the expected form; every feature passes the
checklist (§8) and gets a decision-log entry.

3. Current surface inventory (pre-rewrite)

Keywords (9): pub fn type let import match extern
true false

Punctuation (15 distinct): { } ( ) [ ] , ; . : -> >
>> = + - * / ! .. | ` # (comments only) @
(comments only)

Forms (as found in lang/tests/ and stdlib/):

FormExample
function itempub fn main() -> i64 { ... }
extern itempub extern fn id_bool(b: bool) -> i64;
type aliaspub type P = {x: f64, y: f64}
let bindinglet a = 2 * 2;
importimport std::df
record type / value{x: f64, y: f64} / {x: 1.5, y: 2.5}
row concatp .. {c: 3} .. {d: 4}
sum type[ A unit | B unit ]
sum injection`A(unit) `Pt({x: 1.5, y: 2.5})
matchmatch t { C(_) => 1, }`
closurefn (x) => x
quote (compile-time):( fn (r) => r.b > r.a )
list value[true, false]
list typelist<f64>
pipedf::fromcsv("test.csv") >> df::filter(f)
type variable'a
module path / memberstd::df / df::filter / r.a
equality, comparisona = b, a > b
top-level script modelet a = 1; let b = 2; float(a+b)*c (no fn main)

4. Survey: quote syntax

Constraint: blr's quote must be a syntactic form, not a function call —
a quoted expression is captured as data (compile-time rewrite to node
lists), so the type checker must not resolve a callee named quote and
type-check the argument in normal mode. The type system cannot distinguish
quote-the-function from quote-the-operation, so the parser must. Every
language with this feature made it syntactic:

LanguageQuote spellingNotes
Julia:( expr ) and quote ... endBoth are parser forms; the long form is a compound statement.
Elixirquote do ... endKeyword special form; argument parsed but not type-checked normally. Direct precedent for "quote must be syntactic".
Scala 3'{ expr } (expr), '[ T ] (type)' prefix + paired block.
Scheme/Lisp'(...) / `(...)Prefix quote on a group.
Clojure(quote x) / ` / ~Function and quasiquote operator coexist.
Haskell`[...
Racket#'(...)Prefix on an s-expr.

Observations: (1) the cheap forms are all short prefixes (:, ', #)
— never a bare word, because a bare word would collide with the caller's
namespace (a function could be named quote); (2) short-form languages make
the prefix unambiguous by position — it only ever starts an expression;
(3) the paired-block form keeps true open/close pairs intact.

Decision (2026-09-04): # prefix quote, #{ ... } paired block form.

  • #expr — quotes the next complete expression (Scheme-' boundary model).
  • #{ ... } — paired form for a group: multiple statements, or nested
    structure where a single prefix expression is hard to bound.
  • Rationale: cheapest possible spelling for a very common form (P9); #
    was free once comments moved to //; prefix-reader precedent (Racket
    #/#', F# #); no P3 collision.
  • Superseded candidates, kept on the record: '{ expr } (Scala 3; true
    pair, unified ' family with 'a, but 3+2 keystrokes) and :( expr )
    (Julia; shortest familiar form for the data community, but not a true pair
    and a third meaning of :).

5. Survey: naming sum case labels

With backticks off the table, how is a case label like A told apart from a
user variable named A? Options considered:

OptionSpellingPrecedentCost
A. Namespace + positionA(unit), match t { A(_) => 1 }Rust, OCaml, Scala, Kotlin, SwiftA resolution rule to document; bare names stay in the danger zone of user variables
B. Backtick prefix (status quo)`A(unit), `A(_)OCamlThe odd character (P1/P4) — what we are trying to remove
C. Owner-qualified pathT::A(unit), ::A(unit), match t { T::A(_) => 1 }Rust (Enum::Variant), Zig (Color.red), Swift, GoVerbose in match arms (accepted — arms are self-documenting)
D. case keyword in patternsmatch t { case A(_) => 1 }Scala, Go switch, JavaKeyword per arm (P5); still leaves injection naming to A or C

Decision (2026-09-04): C — owner-qualified paths, with a bare top-level
form.

  • T::A(unit) — case A of sum T, qualified by its owner.
  • ::A(unit) — leading-:: top-level form, superseded by the
    anonymous 'A(unit) (§5.1).
  • Match arms take the same path form: match t { T::A(_) => 1, T::B(b) => b.x }.
  • Inside the definition, cases are written bare — declaration context binds
    them: type T = [ A unit, B P ] defines the labels T::A, T::B.
  • Why over A: a value can never begin with ::, so labels cannot collide
    with user variables in any position — no resolution rule needed; the
    "PascalCase variable" concern is void by construction. Rides the existing
    :: path syntax (std::df, df::filter) — no new token. Self-documenting
    arms; same-named cases across sums coexist (T1::A, T2::A).
  • Accepted cost (recorded against P9): match arms are longer (T::A(_) vs
    A(_)); long owners can alias through the module path.

5.1 The construction ambiguity (2026-09-07)

The §5 decision made an injection a path application — T::A(x) is
syntactically identical to calling a function A in module T. The first
implementation (commit 2026-09-07, decision-log entry of the same date) broke
the tie in the crust lowering: an application whose final path segment is a
case of an in-scope sum lowers to an injection. That is wrong in three ways:

  1. P3 — the same tokens mean two things; the meaning depends on
    declarations, not syntax.
  2. P7 (spirit) — T::A(x) changes meaning if a sum named T appears
    (or disappears) elsewhere in the file. When a local sum T and an
    imported module T both exist, the local sum silently wins.
  3. P12 — an editor cannot classify T::A as constructor or callee
    without full resolution.

Rust, Scala, Swift, and Zig all ship bare Owner::Variant(x) construction
without a marker. They can, because their namespaces forbid T from being
both a module and an enum in one scope — T::A is never both a function
and a case. blr has one item namespace, so the collision is legal; the Rust
model is unavailable here. The mode must be switched by syntax.

Constraint (agreed): the T::A path stays — it names the case explicitly and
reads well — but the path is used only to check that the final segment is
a case of the named sum (and the payload type matches), never to choose
between syntactic modes. The mode switch must be a lexical marker the parser
can see.

OptionSpellingPrecedentProsCons
1. Tick prefix'T::A(x), '::A(x)blr's own 'a type variables; Scheme ' literal; Scala 3 ' quote familyOne char (P9); ' is already in the P4 budget; no keyword (P5); the parser decides with zero resolution; the path is kept for checking; good error site ('T::B(1) → "T::B is not a case of T")A second meaning for ' (P3 rule (a) — the two positions must stay disjoint); POLA risk: some readers reach for "quote" first (mitigated: blr's quote is #, and "literal value" is the closest correct decode)
2. case keywordcase T::A(x)Scala patterns; Go/Java switchSelf-documenting; zero token collisionA keyword per construction (P5, P9 — injections are a common form); P1: case T::A(x) decodes as the start of case analysis (Scala/Haskell), not as building a value
3. @ prefix@T::A(x)C# identifier escape; Java/Scala annotationsA free character, no keywordNot in the P4 budget (needs a justification entry); P1: decodes as attribute/annotation — a wrong guess
4. Backtick prefix`T::A(x)OCaml; the pre-rewrite status quoSolved this exact ambiguity beforeAlready rejected by the §5 rewrite — the odd character (P1/P4) is what we removed
5. No marker (Rust model)T::A(x), resolved laterRust, Scala, Swift, Zig, GoNo new syntaxRequires the separate namespaces blr does not have; P3/P7: meaning depends on declarations; P12: untoolable

Decision (2026-09-07): option 1 — tick prefix, with an anonymous label.

  • Qualified: 'T::A(x) — case A of sum T.
  • Anonymous: 'A(x) — the same, without naming the owner. Both spell
    the same node: a value labeled A. There is no name-level resolution of
    the owner at all — if two sums have a case A, the expected type of the
    position picks which one the value is (a case that is not in the declared
    sum is a row-mismatch type error, like a bad match arm). This replaces the
    leading-:: top-level form, which is gone (from expressions and
    patterns); imported sums stay qualified ('sums::Shape::Circle(2.0)).
  • Grammar: one new term alternative — ' InjectPath ( Expr ), where
    InjectPath is one or more ::-separated identifiers. The tick is the
    mode switch; everything after it is an ordinary path. No type info
    anywhere in the parse. Unit payloads keep the (unit) spelling for now —
    one form — revisit under P9.
  • The path is not consulted by the compiler at all: the label is the
    final segment and nothing more; the owner (T::, import::T::) exists
    for the reader. The lowering emits a labeled value with no lookup — no
    sum-declaration map, no diagnostics from the lowering. Whether the value
    has the right type is the inference phase's job: the injected row is
    unified against the declared type of the position
    ('T::C(unit) where the position expects T = [ A unit, B unit ] is a
    row-mismatch type error). With no declared type in scope (a generic
    'a result, an inferred let), the value simply gets an anonymous sum
    type — the same treatment as any other unconstrained expression.
  • P3 entry for ': type variable (type position) vs injection prefix
    (expression position). The two positions are disjoint nonterminals
    (types occur only after : in parameters, in type declarations, and in
    extern signatures); the grammar must keep it that way. Rule (a) applies —
    recorded, not bent.
  • Patterns: qualified arms stay bare — match t { T::A(_) => 1 } — and gain
    the anonymous form A(_) mirroring 'A(x). Pattern position has no
    application, so no tick: the marker means "literal value spelling", and a
    pattern does not spell a value.
  • Injection is an ordinary term: it composes in arguments, records, lists,
    pipes, and quotes. As the function of an application it parses and fails
    type-check, like (1)(x) — no special ban (unlike #, which must not be
    applied because it captures syntax).

6. Audit: decision points

The pre-rewrite surface, row by row. Status: keep / change / decide.

#CurrentDecisionRationale
1a = b for equalityChange to ==P1, P3: = already means assignment; every mainstream reader reads a = b as assignment. = kept for let/type binding only.
2:( expr ) quoteChange to #expr / #{ ... }Must stay syntactic; see §4.
3Backtick sum labelsChange to 'T::A / 'A tick injectionsSee §5.1. Backticks leave the token budget entirely; the bare-path variant of this row was shipped and superseded the same day.
4[] for both list values and sum types, |-separated, backtick labelsChange: sum types are [ A unit, B P ] — brackets enclosing comma-separated case pairs, no sum keyword (decided). Disambiguation is positional: value position, [ ... ] is a list literal; type position, []T (closed immediately) is a list type and [ label type, ... ] is a sum — the parser decides on the token after [. The , separator matches records and all other sibling lists (P10). [] also serves the list type prefix ([]i64, [][]f64), so [] is the list spelling in both positions — P2 isomorphism made explicit. P3 note: the sum and list readings of [ never co-occur in one position.
5>> pipeChange to single |Shortest mainstream pipe (Unix shell, Elixir, PowerShell); with sums now comma-separated, | has exactly one job. If || logical OR ever arrives, longest-match lexing separates it (or spell it or). > remains comparison-only.
6.. row concatKeep, with a recorded non-decisionP3 (soft): .. is the range spelling in Rust/JS/Python; blr has no ranges. Recorded: if ranges arrive they get different syntax. Row concat is common in blr (P9) and p .. {c: 3} is decodable.
7{ } for blocks and record literalsKeepP3 rule (a): position-disambiguated pair; Rust/C mainstream spelling. Exact disambiguation rule pinned in the EBNF.
8true / false as keywordsChange to built-in identifiersP5: they inform nothing. Frees two names.
9match as keywordKeep (keyword)P1 beats P5: the familiar spelling (Rust/OCaml/Scala). Recorded as an explicit override.
10pub/extern prefixingKeep and codifyP10: modifiers precede the item keyword, order fixed (pub extern fn).
11Closure fn (x) => xChange to fn (x) -> xP2/P3: one arrow for "returns" across type, value, and definition. => reduced to a single job: match arms (keeps the Rust-familiar pattern arrow).
12; statement separatorDecided: exactly one ; per statement; no trailing ;. Write it into the EBNF.One convention (P10); Go-style omission-on-{} adds a context rule and P7 argues against it.
13Top-level script mode (tests without fn main)Decided: keep, codified: a .blr file is a module whose top-level expression is main — items plus at most one top-level expression, which is the module's entry point (script form); pub fn main is the named form of the same entry point. One grammar, no hidden file mode.P7: the mode must be codified, not incidental; P2: both forms are one entry point, so there is one way to express "a program".
14'a type variablesKeepP1: OCaml spelling. Grammar forbids any second type-variable style.
15:: module pathsKeepP1: Rust/C++ spelling; also carries sum-label qualification in injections ('T::A) and match arms (T::A(_)), §5.1 — one mechanism.
16#, @ comment-only; no doc-comment ruleChange: comments are //; a // directly above an item (no blank line) is its doc commentGo's doc-comment model — the only strong-precedent style reusing the plain comment token; // is already what blr tests use. # freed for quote. @ remains an in-comment ticket-marker convention only, not syntax.
17No if yetPre-commit spelling for when it arrivesif c { ... } else { ... } as an expression, no parens around c; while c { ... }. No begin/end, no then, no do.

7. Decision log

Append-only. Every decision, and every deliberate "keep the wart", lives
here.

DateDecisionPrinciple bentJustification
2026-09-04match stays a keywordP5P1: mainstream spelling wins
2026-09-04Case is a lint convention, not a lexical rule (Haskell-style Varid/Conid split rejected)—Essentially no mainstream precedent; confuses users. Labels are path-qualified instead (§5).
2026-09-04Sum case labels are owner-qualified paths: T::A, top-level ::A (§5)P9 (arms get longer)Zero collision with values by construction; rides existing :: path syntax; Rust Enum::Variant precedent.
2026-09-04Pipe is single |, not >> or |>P1, P3Shortest mainstream spelling (shell, Elixir); with sums comma-separated, | has one job.
2026-09-04Quote is #expr; nested/multi-statement quote groups are #{ ... }—Cheapest spelling for a very common form (P9); # was comment-only and comments move to //; prefix-quote precedent (Scheme ', Racket #). Supersedes '{ ... } and :( ... ).
2026-09-04Comments and doc comments are // (doc = comment directly above item)P1Go's doc-comment model; // is already what blr tests use.
2026-09-04Closures use -> (fn (x) -> x); => reserved for match armsP2, P3One arrow for "returns" across type, value, and definition.
2026-09-04List types are []T ([]i64, [][]f64)P2Isomorphic to list literals [v, w]; [] = "list of" in both positions.
2026-09-04Sum types are [ A unit, B P ] — no sum keyword (supersedes the same-day sum { ... } draft)—Positional disambiguation ([]T closes immediately; sums enclose case pairs) removes the need for a keyword (P5); , matches every other sibling separator (P10); no new brace role (P6).
2026-09-04Exactly one ; per statement; no trailing ;P10 (pinned)One convention; no omission context rules (P7).
2026-09-04Script mode kept: a file is a module; its top-level expression is mainP7 (codified, not removed)Script form and pub fn main are the same entry point (P2); one grammar.
2026-09-04#{ ... } quote block: the paired form's node is a block node whose payload is the index of the block's body; let statements in the block are inlined at compile time (the v1 node family has no binding node)P9 (blocks cost one extra node)The rewrite must distinguish the paired form from the prefix form; inlining keeps the v1 node set expression-only. Real let nodes are a follow-up.
2026-09-04# quotes the next complete expression (greedy through operators and closures); a quote is never the function of an application nor an operand of an operator or pipe—The prefix boundary model (Scheme ') needs one unambiguous rule; # 1 + 2 = # (1 + 2).
2026-09-04Doc comments (// directly above an item) are a documented convention only: the lexer does not yet skip // comments and no doc-extraction tool exists — both deferred to follow-up tooling—Unit 9 is doc-only per the plan (no grammar change); the spelling is pinned in docs/src/Syntax.md now so it is settled before the lexer + tooling land.
2026-09-04true/false are built-in constants (hardwired in the crust resolver, not stdlib-provided): an unshadowed occurrence resolves to a bool literal; shadowing wins—Rides the existing identifier-lookup path (scope > prelude > item), produces a real constant (no fake item to link), and falls through to the standard undefined-item error if not recognized. The built-ins live in the crust's Prelude — one prepopulated map so all predeclared names share a home.
2026-09-04Label-path resolution happens in the crust lowering, not the parser: a path whose final segment is a case of an in-scope sum type is an injection; the parser only emits the path application. Resolution checks local sums by name, unique local cases for ::A, and imported sums via import::Type—The parser has no type information, so T::A(x) is indistinguishable from an item call without it; a path whose module is neither an import nor a sum owner still fails in the item-lookup backstop. Superseded by the next entry (same day).
2026-09-07Sum injection is a tick-prefixed path: 'T::A(v) qualified, 'A(v) anonymous; the leading-:: form is gone. The tick switches the syntactic mode (the injection is the pre-existing air-layer Expr::Variant(label, payload) node) and the label is the path's final segment; the owner is documentation for the reader. The lowering consults no sum declarations — type-checking the injection is inference's job (the injected row unifies against the declared type of the position). Match arms stay bare: T::A(_) qualified, A(_) anonymous (§5.1)P9 (one char per injection); P3 rule (a) for ' (type-position vs expression-position stay disjoint)A bare T::A(x) is indistinguishable from a path function application, and blr's single item namespace means both readings can be live — the Rust/Scala resolve-later model only works with disjoint namespaces. The 2026-09-04 crust-resolution entry above bent P3/P7/P12 to avoid a marker; the marker costs one in-budget char and restores all three. Superseded in part by the next entry (same day): the lowering's name-check of the path is also dropped.
2026-09-07Lowering no longer consults sum declarations: Lower loses its sum_labels map (and the alias_source it only fed); an Inject lowers to a labeled value using only the path's final segment. A wrong case or payload is caught by inference unifying the injected row against the declared type of the position — the same mechanism that catches a bad match arm; with no declared type in scope the value gets an anonymous sum type, like any unconstrained expression—The name-check duplicated what unification already does when a declared type is present, and where none is present it had nothing to check against anyway — a declared type should constrain its values, not be carried into the lowering as a table of expectations.
2026-09-07Unbound rows reachable from an item's signature stay open only when reachable from its parameter type; rows reachable only from the return type are closed to the fields the code actually referenced (the existing close_unbound_rows machinery). Fixes the "main dropped from exports" bug (blr#23): an unconstrained sum in an inferred result left a free row in main's type, making it row-polymorphic, never instantiated (nothing calls main), and silently dropped by the monomorph pass — the component was then left with no main export (no entry found for key at nucleus/component.rs:598)—On the current surface a call site cannot name an expected output row (no annotated let, no type application; row instantiation at monomorph is TypApp::Row = todo!), so a return-only free row is vacuous polymorphism: fatal for the entry item, silent Unit corruption for helpers. Parameter-position rows are inputs call sites instantiate and stay open. Rejected alternatives: closing all rows for main only (leaves the helper version broken; puts a name special-case in the type checker); synthesizing a bottom instance in the monomorph pass (the one-TypApp-per-layer machinery cannot instantiate multi-row schemes). Revisit if row instantiation at call sites lands: a declared-context caller could then constrain a callee's return row.
2026-09-07Keep the wart: a single-case sum is represented by its payload (tagless), and its boundary/export type is the payload's type — 'A(41) of a one-case sum prints 41, and type S1 = [ A i64 ] exports as s64, not a variant. The same punning applies to single-field records (blr#24)P1/P2 (the value no longer looks like its type at the boundary)The 1-case discriminant is a constant; the punning is uniform across all four row-algebra combinators (unwrap_single/unwrap_prj) and zero-cost in the layout. Consequences accepted: the case label is visible to the type checker and to matching, but not to printing or to the module boundary (two distinct 1-case sums share a boundary type). Recorded alternatives if this ever bites: (B) always tag — Rust model, label in value and WIT variant, costs a discriminant per 1-case value and re-litigates the layout; (C) tagless values but a WIT type alias for 1-case sums — recovers boundary type identity at zero layout cost.

8. Review checklist

Before any syntax is added or changed, answer (and show the answers in the
PR):

  1. Familiar? Would a Rust/OCaml/JS reader decode it unprompted? (P1)
  2. Isomorphic? Does the type look like its values, the definition like
    its type? (P2)
  3. One meaning? Does this token mean only one thing in this code? (P3)
  4. In the budget? Every symbol from the allowed set? (P4)
  5. Keyword justified? Does it change parsing, or could it be an
    identifier? (P5)
  6. Balanced? Which open/close pair wraps it, and is that pair's role
    pinned? (P6)
  7. No hidden state? Would the same tokens on any other line parse the
    same? (P7)
  8. Composable? Works in every expression position? (P8)
  9. Right weight? Short-and-common or long-and-rare, matching frequency?
    (P9)
  10. Uniform placement? Sibling separators, trailing commas, : usage
    match the one convention? (P10)
  11. Named right? Case conventions followed? (P11)
  12. Toolable? Highlightable/formattable without types; in the EBNF; with
    a good error message? (P12)

A "no" requires either a fix or a new entry in the decision log (§7).