ADR-0011: Casts compile to a cast opcode; :: is postfix and failure is :undefined
Copy Markdown
View Source
Status: accepted (2026-08-09)
Decision record for px-2r5.1, the design gate on the px-2r5 epic
(PostgreSQL-style :: type casts). It settles the compiled form, the
grammar slot, the type vocabulary, the failure rules, and whether casts also
exist as functions. The full conversion matrix and per-cell parse/format
rules live in
docs/research/260809-px-2r5.1-cast-conversion-matrix.md;
this ADR carries the rules that generate that matrix, not its cells.
Context
expr::type converts a value to a named type at evaluation time -
"42"::integer is 42. It is the first construct whose result type is
named in the source text, and the language today has no conversion path at
all: no conversion functions exist, and the evaluator deliberately never
coerces ("opcodes validate, they do not coerce" - docs/isa.md §2). A
predicate over dirty data - a "42" stored where an integer belongs -
currently has no recourse.
The shaping question is the compiled form: a new cast opcode (which mints
ISA v4 and owes ADR-0003's paperwork), or lowering to the existing call
opcode against builtin conversion functions (no ISA change). Lowering looks
cheaper until the function layer's actual properties are laid against what a
cast needs; three of them are disqualifying:
- The function set is host-overridable by construction -
opts[:functions]merges last and silently shadows builtins by name. A host registering a function namedintegerwould redefine what every storedx::integerartifact means. - The function set is explicitly outside the ISA (
docs/isa.md§5, §6). Cast semantics need to be normative - the same matrix in every sibling, pinned by the corpus at the instruction level - which lowering can only achieve by promoting specific function names into the spec: an opcode with extra steps and a worse wire format. call's failure model is wrong for casts. Everycallfailure is a hardEvaluationError; casts need the soft semantics below, and carving a cast-specific exception intocallwould change its semantics under its own name, which ADR-0003 forbids.
What lowering was protecting - not touching the ISA - stopped being worth protecting when ADR-0003 landed: an additive version is a minor release and a page of paperwork.
Decision
expr::typecompiles to a newcastopcode: operand is the type name as a string, pops 1, pushes 1, never touches the context. It mints ISA v4 in a new corpus tier 7 (casts), following v3's precedent that a version's new opcodes get their own tier. Additive, so a minor release and no migration note. The AST node is{:cast, expr, type_name, position}, printed by the StringVisitor at postfix precedence. Acastwhose operand is not one of the seven names below falls under the standing malformed-operand rule (unknown_instruction).::is postfix, tighter than unary minus, chainable. The lexer gains a::token (no collision: the only:today is in object literals, where two colons are never adjacent), and the grammar's postfix production becomesprimary ( "[" expression "]" | "." IDENTIFIER | "::" TYPE_NAME )*. Postfix already binds tighter than unary minus, so-1::integeris-(1::integer)with no extra work, matching PostgreSQL. Chained casts parse left-to-right out of the same loop and are load-bearing for composition: the matrix keeps each edge minimal, and"2026-08-09"::date::datetimeis the supported spelling of "date-shaped string to datetime".The vocabulary is the seven scalar ISA type names (
docs/isa.md§3):integer,float,string,boolean,date,datetime,duration- notlistormap, whose construction the language already serves. The names are contextual identifiers, not keywords, and stay valid as variable names everywhere else. An unknown name after::is a parse error: the type name is static text the author wrote, so it fails at authoring time, and a compiled artifact can never contain an invalid cast.Failure is soft, by two rules that generate the whole matrix:
:undefined::typeis:undefinedfor every target - the sparse-data rulecompare,in, andcontainsalready follow.- Cast is total over values: a conversion that cannot produce a value of
the target type pushes
:undefined, never an error. The author's half of a cast - the type name - already failed at parse time; what remains at evaluation time is the data's half, and data problems go soft here, as at acomparemismatch or anaccessmiss.x::integer > 5on a row holding"abc"is:undefined, falsy at a jump, and the run continues.cast's only error paths are the VM-level ones every opcode has (insufficient operands, malformed operand).
No bare conversion functions.
::is the only surface. The registry's flat names collide with user variables, two spellings would make the StringVisitor's output a choice instead of a fact, and the function layer is host-shadowable - the property that disqualified it as the compiled form would return as an alias.
Consequences
- ISA v4 exists once this ships:
docs/isa.mdgains thecastrow, a §5 subsection carrying the conversion matrix as normative semantics, and a v4 history line;Predicator.Instructionsgains"cast" => %{isa: 4, tier: 7}and@isa_versionmoves to 4. The matrix is ISA-normative, not function-layer-optional: a sibling claiming v4 implements exactly it, and the corpus (px-2r5.5) exercises it. - The epic's children are unblocked with their shapes fixed:
px-2r5.2lexes/parses with the parse-time vocabulary check,px-2r5.3adds the opcode end to end,px-2r5.4teaches the StringVisitor the node,px-2r5.6documents the surface syntax. - A minor release carries it; no stored artifact changes meaning.
docs/isa.md§2's "no coercion" line stays true with a clarifying clause (px-2r5.6): opcodes do not coerce implicitly;castis the author asking. The soft-failure precedent is bounded tocastand is not a license to soften any validating opcode.- Rejecting call-lowering does not close the door on future functions that
construct typed values (a richer
Date.parse, say); it closes the door on::meaning a function call.