The Palladium Language Specification

August 31, 2026 · View on GitHub

Version: 0.3 Date: 2026-08-22 Supersedes: v0.2 (2026-08-22), which described only the implemented subset and disowned the rest; and language_specification.md v1.0.0-alpha (2025-01-19), which described an intended language as though it were built.

0. How to read this document

This document has two parts, and the separation between them is the whole point.

Part I — Normative says what Palladium is. It is the definition of the language. It does not change when the compiler changes, and it is not a claim about pdc. Every real language specification is written this way: ISO C defines C, and no sentence in it becomes false because a particular compiler is incomplete.

Part II — Implementation status annex says what pdc does today, section by section, with a source location or a command output for every row. It is allowed to be embarrassing. It is not allowed to be absent, and it is not allowed to be vague.

Neither part may stand in for the other. A specification that silently shrinks to fit the implementation stops being a specification; an implementation status page that inherits the specification's confidence stops being a measurement. The failure this project actually suffered was the second kind — documentation that read as status while describing a language nobody had built — and the repair is the split, not the amputation.

Annex vocabulary:

MarkMeaning
implementedWorks end-to-end: parses, typechecks, generates C, runs.
partialParses, but breaks downstream — a compile error later, or (worse) wrong C. Each entry names the failure.
unimplementedNot built. Either unparseable or explicitly rejected.

A claim in Part II without a file:line or a reproducible command is a bug in this document. A claim in Part I needs no such citation, because it is a definition; what it needs is to be consistent with the rest of Part I and with the feature documents it links to.

Citations in the annex were re-derived against the working tree at commit abeb665. The v0.2 citations into src/codegen, src/parser, src/typeck and src/driver had been taken from the pre-cleanup revision f323cf1 and were off by 16 to 380 lines; several named unrelated code. Those are corrected below and the corrections are noted where the difference changes the claim.

Per-feature index with the same evidence, organised by feature rather than by section: docs/reference/features/feature-index.toml.


Part I: Normative specification

N1. Overview and design commitments

Non-normative pointer, not part of the definition: implementation status → A1 — partial: the C backend works, the LLVM backend is skeletal

Palladium is a statement-oriented systems language, compiled ahead of time, with no garbage collector and no runtime type information. It exists to make three claims true at once, and those three are the reason for it to exist rather than to be a Rust dialect:

  1. Asynchrony is an effect, not a colour. There is no async keyword and no .await operator. See N7.
  2. Termination is provable. A function or a whole crate can be required to terminate, and the compiler discharges the obligation. See N8.
  3. Lifetimes are inferred. Memory safety is Rust's, but the 'a bookkeeping is the compiler's job. See N9.

The reference implementation compiles to C and links with the system C compiler. That is an implementation strategy, not a language property: nothing in Part I depends on the target being C, and an LLVM backend is a second implementation of the same definition.

N2. Lexical structure

Non-normative pointer, not part of the definition: implementation status → A2 — partial: floats, chars, escapes, nesting comments and the attribute token all lex; no hex/binary/octal literals, and every attribute is refused because none is implemented

Source is UTF-8.

identifier      = ( letter | '_' ) { letter | digit | '_' } ;
integer_literal = [ '-' ] digit { digit } ;
float_literal   = digit { digit } '.' digit { digit } ;
char_literal    = "'" ( char | escape ) "'" ;
string_literal  = '"' { char | escape } '"' ;
boolean_literal = "true" | "false" ;

Comments are // to end of line and /* … */, nesting, and are whitespace.

Attributes are lexical: #[name], #[name(args)] on an item, and #![name(args)] at the top of a compilation unit. They carry totality obligations (N8) and are the extension point for future annotations.

N3. Program structure and items

Non-normative pointer, not part of the definition: implementation status → A3, A4 — partial

A compilation unit is a sequence of imports followed by items.

item = function | struct_def | enum_def | trait_def | impl_block
     | type_alias | macro_def | const_item | static_item | module ;

static_item was added to this production on 2026-08-26, resolving a conflict between two normative files rather than a formatting difference. grammar.ebnf has defined static_item = [ "pub" ] "static" [ "mut" ] identifier ":" type "=" expression ";" throughout, and 1.0-requirements.tsv owes N3-10, "Top-level static items" — while this list named const_item and no static at all. An implementation reading only this line would have shipped const and refused static and been able to cite the specification for it. Recorded here rather than silently corrected, per the repository's rule that a fact conflict is written down and not resolved by picking a winner in passing.

Functions take typed parameters, may declare a return type, and are expression-oriented: a body's trailing expression is its value. There is no async modifier (N7).

A const names one value; a static names one address. Both are written at the top level with a mandatory type and a mandatory initialiser, both are visible to every function in the file regardless of order, and mut is what makes a static assignable — the same rule let already carries, applied at the top level rather than excepted there.

Structs and enums are the product and sum types. Enum variants may be unit, tuple or struct shaped.

Macros are one system — pattern-based, hygienic by default. There is no split between a declarative macro language and a procedural one.

Full feature list: PALLADIUM_V1_FEATURES.md.

N4. Types

Non-normative pointer, not part of the definition: implementation status → A5 — partial: no floats, slices or fn types; Option and Result are not built in

Primitives: i32, i64, u32, u64, f32, f64, bool, char, String, (). int is an alias for i64.

OPEN: str and usize are used normatively elsewhere and are not in this list. implicit-lifetimes.md writes ref str and position: usize in normative examples, and N14 gives string_char_at a char return while string_len returns i64 rather than a size type. Under this list ref str names no type. Two ways out, and the choice is the owner's: add str (a borrowed string slice, the natural referent of ref) and usize (an index type) to the primitive set, or rewrite those examples to ref String and u64. This is flagged rather than decided because adding two primitives is a language change, and because the page carrying the inconsistency had never been reviewed — it was found by reading it for the first time in four rounds, not by any gate.

Composites: arrays [T; N], slices [T], tuples (A, B), references (ref T / ref mut T, see N9), function types fn(A) -> B, and named types with generic arguments Name<A, B>.

Option<T> and Result<T, E> are in the prelude. ? propagates a Result's error to the caller, converting error types where a conversion exists.

Type inference is local and does not require annotations on let bindings or on most expressions. Const generics (struct Buffer<const N: usize>) are generic parameters evaluated at compile time.

N5. Statements and expressions

Non-normative pointer, not part of the definition: implementation status → A6 — partial: if/match/blocks/loop are expressions, with else if, compound assignment, bitwise, ranges, as and x.f(); no closures and no try { }

Statements: let, assignment, return, break, continue, unsafe { }, and expression statements.

if, match, and blocks are expressions. let x = if c { 1 } else { 2 }; is well-formed, and so is else if. loop is an infinite loop, exited with break, which may carry a value.

Closures are expressions: anonymous functions with inferred capture mode, and a move form that transfers ownership.

try { … } scopes error handling, catching and transforming a Result locally.

Operators: arithmetic + - * / %, comparison == != < > <= >=, logical && || !, bitwise & | ^ ~ << >>, compound assignment += -= *= /= %=, ranges .. and ..=, and as casts. Unary minus binds tighter than multiplication, so a * -b is a * (-b).

N6. Patterns

Non-normative pointer, not part of the definition: implementation status → A7 — partial: literal, range, tuple, or- and @ patterns with guards; no slices, non-enum struct patterns, ref/mut or field shorthand; exhaustiveness for every scrutinee type

Patterns appear in match arms, let bindings and parameters:

pattern = '_' | identifier | literal | range_pattern
        | path [ '(' pattern { ',' pattern } ')' | '{' field_patterns '}' ]
        | tuple_pattern | slice_pattern
        | pattern '|' pattern
        | identifier '@' pattern ;

Arms may carry guards (if cond). match is exhaustive: a non-exhaustive match is a compile error, not a silent fall-through, and this applies to every scrutinee type, not only enums.

N7. Effects and asynchrony

Non-normative pointer, not part of the definition: implementation status → A6.5 — unimplemented: the compiler has async and .await, which this section removes

Full definition: async-as-effect.md.

Asynchrony is an algebraic effect, not a function colour.

  • There is no async keyword and no .await operator. A function that performs an asynchronous operation is written exactly like one that does not.
  • Effects are inferred from a function's body and propagated to callers, transitively. The propagation is a fixed point over the call graph, not a single pass, and an unresolved callee is not assumed pure.
  • Independent effectful operations are parallel by default. Sequencing is requested, not accidental.
  • Effect contexts scope policy over a block rather than threading it through every call: with_timeout(5.seconds) { with_retry(3) { … } }.
  • There is no async runtime and no Future boxing. Effect tracking is entirely static and has no runtime representation.

Two escape hatches, and only two: effect::sync { … } forces sequential execution, and an explicit -> async T return type pins an asynchronous boundary.

The effect vocabulary is not limited to asynchrony: IO, memory, panic and unsafe are effects on the same footing, which is what makes "this function is pure" a statement the compiler can check.

N8. Totality

Non-normative pointer, not part of the definition: implementation status → A2 — unimplemented: attributes do not lex, so no totality syntax reaches the parser

Full definition: totality-checking.md.

Palladium can prove that a function terminates.

FormMeaning
#![total(strict)]Crate-level: every function must be proven total, and unsafe is not permitted.
#[total]Per function: the compiler must prove this one terminates.
#[decreases(expr)]The termination measure: expr strictly decreases, in a well-founded order, at every recursive call.
#[total(fuel = N)]Bounded termination: at most N steps.
#[partial]Explicit opt-out; termination is not being proven here.

Structural recursion on an inductive type needs no measure — a recursive call on a strict subterm is proven automatically. Failure to discharge an obligation is a compile error; there is no mode in which an unproven #[total] function is accepted.

N9. References and lifetimes

Non-normative pointer, not part of the definition: implementation status → A9 — unimplemented: ref is not a keyword and there is no region inference

Full definition: implicit-lifetimes.md.

FormMeaning
ref TShared borrow. Replaces Rust's &T.
ref mut TMutable borrow. Replaces Rust's &mut T.
ref<'a> TAn explicitly named region, for cases inference cannot resolve.

There are no 'a parameter lists on functions, structs or impls. A region name appears only inside a ref<…>, and only where the compiler has asked for one.

The safety guarantee is Rust's, unchanged: no use after free, no aliasing ref mut, checked at compile time with no runtime cost. What is removed is the annotation burden, not the analysis. When inference cannot determine a region, that is a compile error naming the ambiguity, never a guess.

N10. Traits and generics

Non-normative pointer, not part of the definition: implementation status → A4.4, A5 — unimplemented: traits emit no code; generic struct fields are rejected in codegen

Generics are monomorphised: a generic function or type is instantiated per concrete argument, so abstraction costs nothing at runtime. Type parameters may carry bounds (<T: Display>) and where clauses.

Traits define shared behaviour: method signatures with optional defaults, associated types, and static dispatch through bounds. Trait methods take a self receiver.

These two are defined in detail by design documents rather than restated here:

Those three documents carry a dual-axis banner: normative language definition, compiler status unimplemented. That is the same relationship every section of Part I has to Part II, and it replaces the older single-axis "PROPOSAL — not implemented" banner, which was accurate about the compiler and wrong about the language. Material in them that is genuinely still undecided sits under an explicitly non-normative open-design heading in each file, so that "not yet built" and "not yet decided" cannot be confused again.

N11. Modules

Non-normative pointer, not part of the definition: implementation status → A3 — partial: import works, there is no mod item

Modules are file-based, with nested paths and public/private visibility. Imports:

import std::math;
import std::io::{read, write};
import std::collections as col;
import std::prelude::*;

Design detail: docs/design/module-system.md, read on the same terms as N10.

N12. Memory model

Non-normative pointer, not part of the definition: implementation status → A9 — partial: checked but not typed; String is Copy in the implementation

Ownership and borrowing are Rust's: each value has one owner, moves are the default, borrows are checked at compile time, and there is no garbage collector.

Values with a destructor are dropped at end of scope. String is an owned, heap-allocated, UTF-8 value with move semantics.

unsafe { } is where the compiler's guarantees are suspended and the programmer's take over. It is restricted rather than unrestricted: it is auditable, isolated, and forbidden inside a #![total(strict)] crate.

A &mut T may be taken only of a binding declared mut. Taking &mut of an immutable binding is a compile error.

N12.1 Array parameters — OPEN DECISION

This subsection is not yet decided. It is written as two options because the choice is the owner's, and choosing silently would be worse than leaving it visibly open. Everything else in Part I is settled; this is not, and no reader should treat either option below as the rule.

The question: does a [T; N] parameter copy the caller's array, or alias it?

Nothing in this specification has ever answered that. §N12 defines moves, borrow-checking and destructors and says nothing about array parameters. It matters because [T; N] has a compile-time size, so a copy is a coherent option in a way it is not for a slice, and because without a rule the callee's a[0] = x is either a local edit or a caller-visible mutation, with no way for a reader to tell which.

The two options are not symmetric — they differ in what they cost and in what they make of the other two questions below.

Option A — value semantics. [T; N] is a value type. Passing one copies N * sizeof(T) bytes; the callee's writes are invisible to the caller. Then &[T; N] and &mut [T; N] have the job every reference has: avoiding the copy, and opting into caller-visible mutation respectively. This is coherent with [T; N] being sized and with §N12's "moves are the default" — and it is the only option under which the three spellings mean three different things. Cost: every array argument is a memcpy unless the author remembers &. For a compiler written in this language that is a real cost, and the bootstrap subset has been avoiding it by convention already.

Option B — reference semantics. [T; N] always aliases the caller's storage, matching C's array-to-pointer decay. Then &[T; N] and &mut [T; N] are redundant spellings, and the specification should say so and pick one: either they are forbidden, or &mut becomes the only permitted spelling for a parameter the callee writes through, with bare [T; N] read-only. Cost: the language inherits a C wart, and "moves are the default" acquires an exception that has to be stated everywhere arrays are discussed.

Two dependent questions, which cannot be answered before the choice above:

  1. What do &[T; N] and &mut [T; N] mean? Under A they are the no-copy and mutable-alias forms. Under B they are noise unless promoted to the mutation marker.
  2. Is mut on a parameter part of the type, or a binding mode? bootstrap-subset.md currently requires mut on every struct and array parameter, and benchmarks/palladium/bubble_sort.pd:11 follows it (fn bubble_sort(mut arr: [i64; 45000], n: i64)). If mut is a binding mode — a statement about the callee's local name — it cannot also be what makes a mutation caller-visible. If it is part of the type, then mut arr: [T; N] is a third reference spelling and Option B's redundancy problem gets worse, not better.

Until this is decided, the bootstrap subset's convention governs by default, because it is written down and followed: struct and array parameters are declared mut, and a write through a parameter not declared mut is refused. That is a placeholder with an owner, not a rule with a rationale.

Measured consequences of each choice are recorded in A9.2.

N13. Execution model

Non-normative pointer, not part of the definition: implementation status → A10 — implemented

Execution begins at fn main. Arguments are evaluated left to right. A compilation unit without a main is a library.

N14. Builtins and the standard library

Non-normative pointer, not part of the definition: implementation status → A8 — partial: the registry is exactly these 34 names and all 34 are callable; signatures still differ (no Result); stdlib/ does not parse

A builtin is an operation the compiler knows intrinsically: it is in scope without an import, its name is reserved, and it has no Palladium definition a program could read or replace. The normative content of this section is the surface — which capabilities are builtin, what their signatures look like, and what distinguishes them from library code — not a name list.

An earlier draft of this section delegated the normative list to docs/reference/builtins.md, which scripts/gen-builtin-docs.py generates from src/builtins.rs. That was a mistake and contradicted this document's own premise: it made the language definition change whenever the compiler's table changed, so pdc could have redefined Palladium by adding a row. The generated table is evidence about the implementation and lives in A8. Part I defines the surface; Part II reports what is built.

The normative set, enumerated. "Closed" is meaningless unless the set can be named, so it is named here. These identifiers are reserved: a program may not define or shadow them, and a conforming implementation provides all of them with these signatures.

NameSignatureEffects
print(String) -> ()io
print_int(i64) -> ()io
panic(String) -> !panic
string_len(String) -> i64pure
string_concat(String, String) -> Stringpure
string_eq(String, String) -> boolpure
string_char_at(String, i64) -> charpure
string_substring(String, i64, i64) -> Stringpure
string_from_char(char) -> Stringpure
string_to_int(String) -> Result<i64, ParseError>pure
int_to_string(i64) -> Stringpure
char_is_digit(char) -> boolpure
char_is_alpha(char) -> boolpure
char_is_whitespace(char) -> boolpure
arg_count() -> i64io
arg_at(i64) -> Stringio
file_open(String, OpenMode) -> Result<File, IoError>io
file_read_all(File) -> Result<String, IoError>io
file_read_line(File) -> Result<Option<String>, IoError>io
file_write(File, String) -> Result<(), IoError>io
file_close(File) -> Result<(), IoError>io
file_flush(File) -> Result<(), IoError>io
file_seek(File, i64, SeekFrom) -> Result<i64, IoError>io
file_exists(String) -> boolio
path_exists(String) -> boolio
path_is_file(String) -> boolio
path_is_dir(String) -> boolio
create_dir(String) -> Result<(), IoError>io
create_dir_all(String) -> Result<(), IoError>io
remove_file(String) -> Result<(), IoError>io
remove_dir(String) -> Result<(), IoError>io
remove_dir_all(String) -> Result<(), IoError>io
read_file_to_string(String) -> Result<String, IoError>io
write_string_to_file(String, String) -> Result<(), IoError>io

Thirty-four names. File, OpenMode, SeekFrom, IoError and ParseError are prelude types (N4); a File is an opaque handle, not an integer.

Reconciliation against src/builtins.rs, name by name. The implementation defines 34 names; this table defines 34. Set arithmetic, computed from the table above and the compiler's own registry:

  • normative − implemented = none. Every one of the 34 names exists in pdc.
  • implemented − normative = none, since 2026-08-23.

The two sets are equal, and that equality is now a check rather than a paragraph: src/builtins.rs::test_registry_is_exactly_the_normative_builtin_set parses the table above out of this file and compares it against the registry in both directions, so a thirty-fifth builtin is a red test and not a reader's job.

(Until 2026-08-23 the implementation defined 38, and implemented − normative was file_open_ex, file_close_ex, file_read_ex and file_write_ex — a parallel handle API that existed because OpenMode does not. None of the four was callable, all four were refused at typecheck, and no .pd file in the tree named one. They were REMOVED FROM THE REGISTRY rather than repaired: a compiler table that carries names this section does not define is a second definition of the builtin surface. Their C wrappers are still emitted — dead code in src/codegen/mod.rs, recorded as owed in A8 and not as done.)

What does not match is signatures, and closing the name sets did nothing about that: the filesystem builtins return i64/bool handles rather than Result, and string_char_at returns i64 rather than char because char is not a type yet. Itemised in A8. (A third divergence — file_flush and file_seek registered but not callable, their C wrappers taking an opaque FileHandle no Palladium type can hold — was closed on 2026-08-23 by re-basing both onto the long long handle table.)

(A previous version of this annex said "36 builtins", inherited from the pre-cleanup specification's section heading. It was never right: src/builtins.rs had 38 from 191f8c1, which made it the single table, until the four *_ex names left it. Corrected at every site.)

This table is the definition, and it is written independently of the generated one on purpose — an earlier draft delegated to it, which would have let pdc redefine Palladium by adding a row.

Three normative constraints, which are properties of the language rather than of any table:

  1. Builtins are closed. The set is exactly the 34 names above. A program cannot define a new builtin, and the set does not vary by target. A capability that varies by target belongs in the standard library.
  2. Builtins are not privileged in the type system. They take and return ordinary types; there is no builtin-only type and no builtin-only calling convention.
  3. Filesystem builtins are effectful in the sense of N7, and their effects propagate to callers. Output builtins are effectful. String, character and conversion builtins are pure.

Above the builtins sits a standard library, written in Palladium and read like any other module: core types and traits, collections (Vec<T>, HashMap<K, V>, String, Option<T>), math, buffered and networked I/O, and process/environment access. The dividing line is constraint 1: if it can be written in Palladium, it is library, not builtin.


Part II: Implementation status annex

What pdc does at commit abeb665, per Part I section. Each row is either a source location or a command that was run.

Normative sectionStatusWhere the detail is
N1 OverviewpartialA1 — C backend works; LLVM backend is skeletal
N2 Lexical structurepartialA2 — no hex/binary/octal, no numeric separators, no raw strings; every attribute is refused
N3 Program structure and itemspartialA3, A4
N4 TypespartialA5 — no slices, no fn types; Option/Result not built in. (This row read "no floats" until N4-04's round; floats landed with N2-03/N4-02 and §A5 has said so since — a pre-existing contradiction inside this table, not a new one)
N5 Statements and expressionspartialA6if, match, blocks and loop ARE expressions now, with else if, compound assignment, bitwise operators, ranges, as casts and x.f(). What is left: no closures (N5-08) and no try { } (N5-09), both owed to M3; unsafe { } parses but N5-02 is owed to M5
N6 PatternspartialA7 — literal, range, tuple, or- and @ patterns with guards; slices, non-enum struct patterns, ref/mut and field shorthand still absent; exhaustiveness for every scrutinee type, with a trap where no arm is taken
N7 Effects and asynchronyunimplementedA6.5, and the divergence list in async-as-effect.md
N8 TotalityunimplementedA2 — attributes do not lex; no checker exists
N9 References and lifetimesunimplementedA9ref is not a keyword; no region inference
N10 Traits and genericsunimplementedA4.4, A5
N11 ModulespartialA3import works; no mod item
N12 Memory modelpartialA9 — checked but not typed; String is Copy; array parameters A9.2; &mut of an immutable local refused A9.3
N13 Execution modelimplementedA10
N14 Builtins and stdlibpartialA8 — the registry is exactly the normative 34 and all are callable; signatures differ (no Result); stdlib/ does not parse

A1. Pipeline and backends

The pipeline (src/driver/mod.rs:49) is:

lex → parse → macro expand → resolve imports → typecheck → borrow check
    → effect analysis (informational only) → unsafe check → optimize → C codegen → gcc

The C backend is the real backend. An LLVM text backend exists (src/codegen/llvm_text_backend.rs, 2543 lines) but is skeletal: break and continue are refused outright, naming the %loop_end_placeholder / %loop_inc_placeholder the TODO would have emitted (src/codegen/llvm_text_backend.rs:944-946, src/codegen/llvm_text_backend.rs:948-950), match is a TODO if/else chain (src/codegen/llvm_text_backend.rs:952-964), and enum construction, ?, macro invocation and await are four separate refusals (src/codegen/llvm_text_backend.rs:1479-1492) — they were ONE catch-all returning the constant 0, which is how two distinct enum variants compiled to byte-identical IR. It also bails on ordinary code — "Unsupported iterator type in for loop" (src/codegen/llvm_text_backend.rs:845-847), "Unsupported binary operator" (src/codegen/llvm_text_backend.rs:1161-1165), "Complex function calls not yet supported" (src/codegen/llvm_text_backend.rs:1302-1305). No conformance row exercises it.

Generated C is linked against runtime/palladium_runtime.c, which supplies 16 file/path symbols. pdc resolves that runtime relative to its own install location — pdc --print-runtime shows which copy it found, and $PALLADIUM_RUNTIME overrides it. (Until 2026-08-22 the path was hardcoded relative to the working directory, so an installed compiler could not link anything.)

The C backend is the only backend. An LLVM text backend exists in the tree (src/codegen/llvm_text_backend.rs) and --llvm selects it, but as of 2026-08-22 it refuses unconditionally: LLVMTextBackend::compile returns CompileError::Unimplemented before it looks at the program. It is retained for development, not for building.

It refuses wholesale rather than per-construct because its gaps are not all loud ones. Seven constructs failed visibly — break, continue, enum patterns, enum construction, ?, await, and stray macro invocations — and each now carries its own diagnostic underneath the gate. Seven more fabricated rather than refused, without saying so:

  • struct field access uses index 0 for reads and writes alike, so p.y reads p.x
  • every unenumerated type becomes i8*, and every call is typed i64 regardless of signature
  • match on a wildcard or identifier pattern discards the scrutinee and never binds the identifier
  • a plain main emits ret void, putting the process exit status outside the program's semantics
  • non-function items (structs, enums) are dropped while expressions still refer to them
  • string collection skips Stmt::Match and Stmt::Unsafe, leaving an undefined @.str.unknown

These do not all fail the same way. The last two emit invalid IR, which an assembler rejects. Some of the others emit IR that is valid and means something other than the source. The demonstrated case is field-zero access: struct Point { x: i64, y: i64 } with print_int(p.y) lowers to getelementptr i64, i64* %4, i32 0, i32 0 and reads x, in a module that assembles, links and runs. The C backend prints 22.

That case is why the gate is wholesale: verifying the assembly cannot detect it, so a gate covering only the loud half would read as protection while providing none.

A2. Lexical structure

The lexer is logos-based (src/lexer/token.rs).

implemented: decimal integers, floats, chars, strings, booleans, identifiers (src/lexer/token.rs). The sign is part of the integer token, so i-1 lexes as i then -1 — and part of the float token too, so x-1.5 lexes as x then -1.5. One convention, not two.

implemented — floats and chars (N2-03, N2-04). 3.5 is one token of type f64; f32 and f64 are distinct C types (float, double) and one checker type, because as casts do not exist yet and no program can observe the difference the checker would be drawing. There is no implicit widening: 1 + 2.5 is a type error naming both types, because C would convert it silently and the language has no cast to write the conversion with. % on a float is refused — C's % does not take a double, so accepting it would emit C that gcc rejects.

A char literal denotes its Unicode scalar and its type is char, distinct from i64. There is no implicit conversion in either direction: print_int('a') and c == 97 are both type errors, and as is how you cross — 'a' as i64 for the code point, 97 as char for the character. N4-04 and N14-04 landed together and had to, because N14 gives string_char_at a char return and the three char_is_* predicates a char parameter: a char type on the literal alone would have left 'a' unusable with every builtin that consumes a character, and builtins over char with no literal to feed them would have been unreachable.

char crosses only to i64, and only through as. 'a' as bool and 3.7 as char are refused by name — four forbidden cells with a fixture each, one per DIRECTION because the defect they correct was symmetric: tests/reject/char_does_not_cast_to_bool.pd, tests/reject/bool_does_not_cast_to_char.pd, tests/reject/float_does_not_cast_to_char.pd and tests/reject/char_does_not_cast_to_float.pd. The code point is the one correspondence a character is defined by, and neither "is a letter truthy" nor "which character is three-tenths past \u{3}" is a question this specification answers. 97 as char is CHECKED rather than trusted — a value outside 0..=0x10FFFF, or inside the UTF-16 surrogate range D800..=DFFF, is not a Unicode scalar and traps at the conversion rather than reaching string_from_char, which would write its low byte and print garbage.

Neither has a printer. N14's builtin set is closed and contains no float_to_string, so a float's digits cannot reach stdout at all; tests/02_types_floats.pd asserts values by bracketing them between comparisons instead.

implemented — escapes (N2-09). In a string literal the set is \n \t \r \" \\ \'; a char literal takes those and \0. Anything else is a compile error that lists the set; it is not passed through. It used to be: "\q" produced the two characters \ and q with no diagnostic, and "\\n" produced a backslash and a LINE FEED because the unescaper was a chain of .replace() calls in which the \n rule reached the second backslash's n first.

\0 is refused in a string literal and legal in a char literal, and the asymmetry is the decision rather than an omission. A String is a non-NULL, NUL-terminated const char* (N14), so "a\0b" denotes three characters and every String operation sees one: print stops at a, string_len answers 1. That was ACCEPTED for one round, with the consequence documented here — and documenting a representation leak does not stop it, it records that it shipped. A literal whose value no operation in the language can observe is the same defect N2-11 refuses one construct along, where an attribute that lexes and is ignored makes the source say one thing and the binary another.

'\0' stays legal because a char literal is a Unicode scalar held as i64 and zero is an ordinary value there, so nothing that was expressible is lost. The route to making the string form legal is length-aware String semantics — a pointer plus a length — which is an N4/N14 change and not a lexer one. Pinned by tests/m2_lexical.rs::{a_nul_in_a_string_literal_is_refused, a_nul_in_a_char_literal_is_still_the_value_zero, the_string_escape_set_is_the_char_set_minus_nul}.

unimplemented: hex (0x), binary (0b), octal, numeric separators, raw strings, \xNN and \u{} escapes, string interpolation. No lexer rule produces any of them.

implemented — attributes lex, and every one of them is refused (N2-10, N2-11). #, #!, #[name], #[name(args)] and #![name(args)] all lex and parse. The set of attributes the compiler implements (KNOWN_ATTRIBUTES, src/parser/mod.rs) is empty, so every attribute is a compile error naming the attribute:

error: unknown attribute `total`
  = note: this compiler implements no attributes yet: `#` lexes so that the surface
    exists, and every attribute is refused so that none can be silently ignored

The emptiness is the point, and the two rows are one change. An attribute that lexed and was then dropped would compile #[total] into a binary with no totality check in it — a source that requests a property and a binary without it, which is the defect class M1 was spent deleting. N8 is therefore no longer blocked lexically; it is blocked on M6 having something to discharge the obligation with.

The 31 keywords the lexer recognizes (src/lexer/token.rs:232), counted by grep -oE '#\[token\("[a-zA-Z_]+"\)\]' src/lexer/token.rs | wc -l:

fn let mut if else while loop return true false for in break continue
struct enum trait impl match import pub as Self self type const static
unsafe async await macro

Note import, not use. mod, use, where, dyn, move, ref, crate, super, extern, try, with, effect are NOT keywords — they lex as ordinary identifiers. loop became one with N5-07 and static with N3-10 (src/lexer/token.rs:313), and the second of those has a cost worth stating: static was previously usable as an identifier, so let static: i64 = x; used to parse and no longer does — tests/m1_c_keyword_idents.rs, which drives a C keyword through every identifier position, had to move that local to goto. The absence of ref is why the normative reference syntax of N9 does not parse; the absence of with and effect is why the effect contexts of N7 do not.

async and await are keywords — the two things N7 says the language does not have are the two the implementation has.

Operators. implemented: + - * / % = == != ! < > <= >= && ||, the compound assignments += -= *= /= %=, the bitwise set & | ^ ~ << >>, ..=, and as casts. $ is lexed and never consumed by the parser; | now IS consumed, as bitwise or.

>> IS NOT A TOKEN, AND THAT IS DELIBERATE. Option<Vec<Stmt>> closes two generic argument lists with two adjacent >, so a longest-match >> token would swallow both and break every nested generic in the tree. The shift is recognised in the parser from two Gt tokens whose spans TOUCH, which is why a > > b is not a shift. << is an ordinary token: the two < of Vec<Vec<T>> always have an identifier between them.

Comments (// line, /* block */) are implemented and they nest (N2-08): /* a /* b */ c */ is one comment, and the c between the two closes is not live source. A regular expression cannot count, so comment scanning is a callback on the / token (slash_or_comment, src/lexer/token.rs) rather than a #[logos(skip)] pattern. A /* that nothing closes is an error rather than a comment to end of file — otherwise deleting one */ would compile a shorter program than the author wrote.

bootstrap/pdc.pd's own hand-written scanner still stops at the first close with no depth counter; the divergence is recorded in bootstrap-subset.md and is not observable, because no PBS-1 source contains a nested comment.

A3. Program structure

program = { import } { item } ;

Imports must all precede items — the parser drains imports first (src/parser/mod.rs:674-675), so an import after a fn is a syntax error.

import = "import" path [ "as" identifier ] ";"
       | "import" path "::" "*" ";"
       | "import" path "::" "{" identifier { "," identifier } "}" ";" ;

Items (src/parser/mod.rs:937): fn, struct, enum, trait, impl, type, macro, const, static. unimplemented: there is no top-level mod or use item — so N11's file-based modules exist only as far as import reaches.

implemented — top-level const and static (N3-09, N3-10), parsed by src/parser/mod.rs:1910, registered by src/typeck/mod.rs:1898 and emitted by src/codegen/mod.rs:3038. Both take a MANDATORY type and a MANDATORY initialiser:

const_item  = [ "pub" ] "const" identifier ":" type "=" expression ";" ;
static_item = [ "pub" ] "static" [ "mut" ] identifier ":" type "=" expression ";" ;
WrittenEmittedAssignable
const X: i64 = 5;static const long long X = 5;no
static Y: i64 = 10;static long long Y = 10;no
static mut C: i64 = 0;static long long C = 0;yes

Not a #define: a macro is unscoped and untyped and would rewrite every later occurrence of that spelling in the file. The C static on all three is INTERNAL LINKAGE rather than the item's own keyword — the output is one translation unit, and a file-scope name with external linkage can collide with a libc symbol the program never mentions.

The type set and the initialiser set are closed, and each excluded form is refused by name rather than left to the C compiler, whose initializer element is not constant names generated code. Types: i32, i64/int, u32, u64, f32, f64, bool. Initialisers: integer and float literals, true/false, and unary and binary operators over them (src/parser/mod.rs:2005) — so a call, another item's name, a string, an array, a struct literal, an enum constructor, an if or a match is a compile error. A String item is refused for the type and not only for the initialiser: a Palladium String is a pointer into a runtime arena, so its value needs code that runs, and nothing runs before main.

A local may not shadow a top-level item, and a parameter may not either. C would accept the shadow and so would the type checker's scope stack; the refusal is at the binding, because for a static mut the two readings differ in whether the program's state changed.

static is a keyword as of N3-10 and is therefore no longer available as an identifier — see the keyword list in A2, which describes the lexer and which this changed. A pub const in a module exports nothing: nothing emits a definition for an imported top-level item, so exporting the name would type-check at the use site and fail at the linker. That is N11 work, not an N3-09 lowering.

Fixtures: tests/03_const_items.pd, tests/03_static_items.pd, and five reject fixtures — tests/reject/const_initializer_calls.pd, const_reads_another_item.pd, const_string_type.pd, const_local_shadows_item.pd, static_assign_without_mut.pd.

A4. Items

A4.1 Functions

function = [ "pub" ] [ "async" ] "fn" identifier [ generic_params ]
           "(" [ params ] ")" [ "->" type ] block ;
param    = [ "mut" ] identifier ":" type | self_param ;
self_param = [ "&" ] [ "mut" ] "self" ;

implemented: parameters, return types, pub, self receivers in impl blocks. unimplemented: default parameter values, pattern parameters, varargs, where clauses.

unimplemented — effect clauses. ![io] does not exist in the surface syntax. Function.effects is hardcoded None by the parser (src/parser/mod.rs:1305, corrected from v0.2's src/parser/mod.rs:1289, which is where the Function literal opens). Effects are inferred afterwards (src/effects/mod.rs) and only printed by the driver (src/driver/mod.rs:176, corrected from src/driver/mod.rs:164-170); they gate nothing. crate::effects:: is referenced from exactly one place in the compiler, src/driver/mod.rs:172.

async fn is accepted and typechecked: async fn g() -> i64 { return 1; } fails with "Type mismatch: expected Future, found Int", i.e. the return type is wrapped in a Future. Under N7 neither the keyword nor the wrapper should exist.

A4.2 Structs

implemented field types: i64/i32/u32/u64, bool, String, [T; N], other structs, enums.

partial — field types that parse and then fail in codegen (all three corrected from v0.2, which was ~250 lines low):

  • generic → "Generic types in structs not yet supported" (src/codegen/mod.rs:2965-2968)
  • reference → "Reference types in structs not yet supported" (src/codegen/mod.rs:2972-2972)
  • tuple → "Tuple types in structs not yet supported" (src/codegen/mod.rs:2980-2984)

A4.3 Enums

implemented: unit, tuple, and struct variants; construction and match both work. pub is honoured (src/parser/mod.rs:988-992, src/ast/mod.rs:182): a module's enum reaches a downstream program only if it said pub, and the refusal for one that did not is Undefined enum type: <name> before any C exists.

This paragraph said the opposite until 2026-08-23 — "pub on an enum is parsed and then silently discarded, EnumDef has no visibility field" — and it was accurate: the parser read the keyword and dropped it for this one item kind, so enum X and pub enum X produced the same AST. The consequence was not cosmetic. Enum-kind discovery unioned every imported enum name into one bare-name set, so an enum in any imported module could misclassify a downstream local type of the same name and the program was refused with Type mismatch: expected Color, found Color. The keyword now decides, and local_type_shadows_import decides the rest.

A4.4 Traits

unimplemented. Traits parse (src/parser/mod.rs:1495, corrected from line 736–960 of the pre-cleanup revision) and then emit nothing — codegen ignores Item::Trait (src/codegen/mod.rs:2397-2400, corrected from line 754–757 of the pre-cleanup revision). Trait method bodies are never typechecked (src/typeck/mod.rs:2764-2765, corrected from src/typeck/mod.rs:3174-3174). Additionally, a trait method declared with a self receiver is a parse error, because trait methods use a separate parameter loop that does not handle self (src/parser/mod.rs:1618-1619, corrected from line 863–897 of the pre-cleanup revision).

So trait Display { fn fmt(&self) -> String; } does not parse, and N10 has no implementation at all.

tests/07_traits_basic.pd PASSES conformance while only printing that traits are unimplemented.

A4.5 Impl blocks

impl_block = "impl" [ generic_params ] [ type "for" ] type "{" { function } "}" ;

implemented: methods become mangled free functions __pd_Type_method (src/codegen/mod.rs:2414-2419, corrected THREE TIMES: from line 1861 of the pre-cleanup revision; on 2026-08-23 from 1174-1180, which was the file-I/O prelude and had nothing to do with method mangling — the line numbers had been tracked through an edit while the target was never re-read; and on 2026-08-25, when 4690ef0 inserted above it). implemented: Self in a method signature resolves to the type the block is for (src/ast/mod.rs:264), and BOTH the type checker and code generation call that one function. Until 4690ef0 the return type was substituted in code generation alone, so fn new(..) -> Self worked while fn area(self) reached the C compiler as struct Self self — a type nothing declares. unimplemented: associated constants and associated types are rejected — an impl body may contain only fn (src/parser/mod.rs:1786-1792, corrected from line 1030 of the pre-cleanup revision). implemented: methods are called with . syntax — see A6.4. Type::method(receiver, args) also works, which it did not when this section recommended it.

A4.6 Macros

partial, and MEASURED — this section used to describe a system nobody had run. The previous version called println! and assert! "implemented"; both fail on every argument anybody would write, and did so before the round that measured them. What follows is what programs do.

A macro is a TOKEN TEMPLATE. macro name!(a, b) { body } stores the body as tokens at definition, substitutes an argument where the body writes $name, and re-parses the result at the call site by rendering the tokens back to source text and re-lexing them (src/macros/expander.rs:454, tokens_to_string). There is exactly one macro system: macro_rules! is refused by name in both item and invocation position (N3-14).

No macro parameter had ever been substituted, in either spelling, until 2026-08-26. token_to_ast_token (src/parser/mod.rs:2170) did not list Token::Dollar, so $x in a body was stored as the identifier Dollar followed by x, and substitute_template (src/macros/expander.rs:372), which keys on Token::Dollar, could never fire. Measured: macro double!(x) { $x * 2 } failed with "Undefined variable or function: 'Dollar'". Completing that one table row is the whole repair.

Every substituted capture is parenthesised. Before that, macro double!(x) { $x * 2 } gave double!(1 + 1) = 3 and double!(2 + 3) = 8: compiled, linked, ran, exit 0, wrong number. Both are pinned in tests/03_macros.pd.

What a macro body may contain, because the stored token type cannot carry the rest: identifiers, INTEGER literals, and single-character punctuation. Everything else is refused by name at the definition:

RefusedWhyFixture
string, float, char, true/false literalsAstToken::Literal is a String with no KIND and the reverse conversion guesses with parse::<i64>(). Measured, all silent: macro s!() { "hi" } printed an EMPTY line, macro pi!() { 3.5 } printed 3.5 as a String, macro yes!() { true } printed true as a Stringtests/reject/macro_body_string_literal.pd
== != <= >= && || -> :: => ..AstToken::Punct is one char; = = is not ==. These used to become an identifier named after the Rust debug spelling, surfacing as "Undefined variable or function: 'EqEq'" three phases latertests/reject/macro_body_two_char_operator.pd
a parameter name written without its $it is a free identifier resolved at the CALL SITE. Measured: macro double!(x) { x * 2 } with let x = 3; at the call site printed 6 for double!(21)tests/reject/macro_bare_parameter.pd
$name that is not a parameterthe unmatched $ was re-parsed and reported as "expected expression, found '$'" at the call sitetests/reject/macro_unknown_substitution.pd
a body that invokes another macroexpansion is a SINGLE PASS, so an invocation produced by an expansion is never expanded and reached the type checker as an internal assertiontests/reject/macro_invokes_macro.pd

Four builtin macros are registered (src/macros/mod.rs:41), and one of them works:

MacroExpands toStatus, measured
vec!(e)[e] — a 1-element array, not a growable vectorthe only usable builtin; misleading name. vec![e], the bracket form, does not parse
println!(e)print(e); print("\n")unusable. println!("x") failed with "expected ')', found identifier 'x'" before the literal rule existed and is a named refusal after it; println!(1) fails with "Unexpected end of file"; println!() and println!(a, b) fail the arity check
assert!(c)if (!(c)) { panic("Assertion failed"); }unusable. assert!(1 == 1) cannot be written — == is refused in an argument for the reason above — and assert!(b) on a bool local fails with "expected ';', found '{'"
dbg!(e)calls print_debugunimplementedprint_debug is defined nowhere (src/macros/mod.rs:167) and dbg!(5) fails with "Unexpected end of file"

Macro hygiene (N3) is unimplemented, and the evidence is BEHAVIOURAL rather than a grep. This paragraph used to rest on grep -rn hygien src/ --include='*.rs' returning nothing, which is a statement about spellings. Measured instead, on programs:

macro m!() { secret }
fn main() { let secret = 42; print_int(m!()); }   // prints 42
macro m!() { n }
fn a() -> i64 { let n = 1; return m!(); }
fn b() -> i64 { let n = 2; return m!(); }
fn main() { print_int(a()); print_int(b()); }     // prints 1 then 2

The first is free capture of a call-site name; the second is one macro body with two meanings chosen by the caller. Expansion is textual by construction — the template is rendered to source and re-lexed — so no fixture can make it hygienic, and N3-13 is an implementation row owned by M5. The "hygiene by refusal" reading was tested and fails: the introduce-a-binding route is not defended by the shadowing rule, it is merely unwritable, because let in a macro body is refused for an unrelated reason.

A5. Types

SyntaxStatusNote
i64, intimplementedint is an alias for i64 (src/parser/mod.rs:3853, corrected from line 2038 of the pre-cleanup revision)
i32, u32, u64implementedprimitive table at src/parser/mod.rs:3842-3849 (corrected from line 2037–2043 of the pre-cleanup revision)
bool, Stringimplemented
()implementedunit
[T; N]implementedone dimension, N an integer literal. N as an identifier parses but is dropped (const generics, below), so such an array is uncallable and its for loop is a compile error
[[T; M]; N]implementedworks as a local, as a parameter, as a struct field and three deep. The dimensions are emitted after the identifier, outermost first (long long grid[3][2]), which is the C declarator shape — composing them into the type instead produced long long[2] grid[3], which gcc refuses outright. Pinned by tests/02_types_nested_arrays.pd. Two refusals by name remain: for over a nested array, because each step would bind a whole ROW and C cannot copy an array by assignment (tests/reject/for_over_nested_array.pd), and an unresolved inner length in ANY declarator position (tests/reject/nested_array_param_inner_length.pd and its struct-field twin)
&T, &mut Tpartialparses, but the typechecker is a no-op: Type::Reference maps to its inner type — "For now, treat references as the inner type / TODO: Proper reference type handling" (src/typeck/mod.rs:744-748, corrected from line 2470–2486 of the pre-cleanup revision). &i64 and i64 are indistinguishable to it.
ref T, ref mut Tunimplementedref is not a keyword; fn f(x: ref String) fails with "expected ')', found identifier 'String'"
Name<A, B>partialsee below
(A, B)implementedone C struct per SHAPE, mangled from the element C types and emitted with a constructor (src/codegen/mod.rs:4905); void* is gone. Arity two or more. A tuple in an ENUM PAYLOAD is refused by name — tuple structs are emitted after the enum definitions because an element may be an enum, and a payload of tuple type needs the reverse order
f64implementedthe type of a float literal since N2-03; C double
f32partialparses and maps to C float, but shares one checker type with f64, so nothing can observe the difference
charimplemented'a' lexes and carries the right scalar (N2-04) and its TYPE is char, distinct from i64 with no implicit conversion either way (N4-04). The five character builtins speak it (N14-04). One C carrier, long long, because a C char holds 8 bits and '한' needs 21; as between char and i64 is a no-op identity cast, and as char range-checks its operand
str, u8, usizeunimplementednot in the primitive table
fn(A) -> Bunimplementedfunction types are unparseable
[T] slices, dyn T, impl Tunimplemented
<T: Bound>, whereunimplementedparse_generic_params accepts bare names only; the : is a parse error

partial — generic argument bug: inside <…>, any identifier whose characters are all uppercase or _ is reclassified as a const generic argument (src/parser/mod.rs:3883-3893, corrected from line 2054–2079 of the pre-cleanup revision). So Foo<T> yields a const-generic T, not a type argument. Only mixed-case names like Vec<Item> reach the type branch.

partial — const generics: they parse, and in codegen an ArraySize::ConstParam is emitted into C verbatim as the parameter's name while an ArraySize::Expr becomes the literal "0" (src/codegen/mod.rs:994-998, corrected on 2026-08-23 from 1204-1206, which was return pd_file_flush(handle); — a citation about const generics pointing at the file-I/O prelude). Neither is monomorphised. (v0.2 said "array sizes from a const parameter resolve to 0" citing src/codegen/mod.rs:649-649; that is the expression case, not the const-parameter case.)

tests/08_generics_basic.pd PASSES conformance while only printing that generics are unimplemented.

A5.1 Option and Result

unimplemented as built-ins. There is no built-in Option or Result — no prelude, no declaration, no lexer or parser support. They are ordinary user enums if you declare them, with no methods and no ?. Declaring one does not make ? work: the operator is rejected outright (see A6.5), because nothing lowers it onto the representation your enum is compiled to. Use match.

unimplemented as built-ins. There is no prelude, no declaration, no lexer or parser support. They are ordinary user enums if you declare them. The only special-casing left is the REFUSAL: ? is rejected outright by the type checker (src/typeck/mod.rs:4976-4976) and again by code generation (src/codegen/mod.rs:6533-6537). It used to typecheck against a Generic{name:"Result"} shape and then emit C for a struct Result layout nothing defines (see A6.5).

A6. Statements and expressions

A6.1 Statements

let, assignment, if/else, while, for … in, match, return, break, continue, unsafe { }, expression statements (src/parser/mod.rs:2359).

  • implemented: let [mut] x [: T] = e;the initializer is mandatory (src/parser/mod.rs:2497, corrected from line 1411 of the pre-cleanup revision); the binding must be a plain identifier (no patterns).

  • implemented: assignment targets — identifier, index, field, deref.

  • implemented: else if (N5-06). After else the parser looks for if and recurses (src/parser/mod.rs:2298-2300), so a chain is nesting and there is no ElseIf node. The branch tail travels with it, which is what keeps a tail-position chain returning. (This bullet read "unimplemented — after else the parser requires {" until 66dab38.)

  • implemented: loop (N5-07), a keyword since src/lexer/token.rs:250, parsed at src/parser/mod.rs:2897 and emitted as C while (1) (src/codegen/mod.rs:4347). Its break may carry a value. (It read "not a keyword. Use while true" until f729cda.)

  • implemented: compound assignment += -= *= /= %= (N5-13), DESUGARED at src/parser/mod.rs:2404-2406 into t = t op v rather than emitted as C's own compound operator — Palladium's + on String is a runtime concatenation call, which C's += cannot express. The residual that buys: the target is written twice, so it is evaluated twice, and a[next()] += 1 calls next() twice.

    AND THAT IS NOT A NORMATIVE VIOLATION, WHICH IS WORTH STATING RATHER THAN ASSUMING. N5 names the compound operators and says nothing about how often the place is evaluated; N13's only evaluation-order sentence is "Arguments are evaluated left to right", and the left-hand side of an assignment is not an argument. So N5-13 is satisfied on the requirement as written. It is a deviation from the language this one is modelled on, where the place is evaluated once, and it is recorded here because the next person to write that rule down should know it costs a place-expression lowering — binding the subscript to a temporary before the read — and not a one-line change. (It read "unimplemented — verified: Expected expression, but found '='" until ef74eba.)

  • implemented: self IS A PLACE BASE. The production is place = identifier | "self" | place '[' expression ']' | place '.' identifier | '*' identifier, and self needed spelling separately because it is a KEYWORD, not an identifier. That omission was the whole defect: self.n = v; never reached the assignment path and came back as Expected ';' after expression, but found '='. compound_assign routes through place, so self.n += 1, self.d[i] = v and chains came with it. Whether a write MEANS anything is a property of the receiver, which the grammar cannot state, so three of the four answers are refusals with named diagnostics — a run fixture cannot witness a refusal, so each cites its own reject fixture:

    • &mut self — the writable form; writes propagate to the caller. Witnessed by tests/04_self_place.pd, which re-reads through a separate &self call after the mutating call returned, so a by-value receiver mutating a copy could not produce the same transcript.
    • &self — a SHARED borrow: refused. It used to lower to self->n = v against a const struct C* and be caught by gcc, which states a rule of this language in the wrong place. tests/reject/self_write_through_shared_receiver.pd.
    • self — a COPY, and not a mut binding: refused by the ORDINARY immutability rule, since there is no mut self form to declare. It used to compile, link and run while the caller observed nothing. tests/reject/self_write_through_by_value_receiver.pd.
    • self = v — the receiver binding is not reassignable in any form. tests/reject/self_is_not_reassignable.pd.
    • A let from self COPIES the pointee and creates no alias, so writes to that binding are ordinary local mutation and are not receiver-governed: let mut a = self; a.n = 9; lowers to C a = (*self); a.n = 9LL; and the caller's object is untouched. The asymmetry with the by-value receiver above is deliberate — both are writes the caller never sees, but self's form is a promise the signature makes to every caller, while a is a local the programmer declared mut on the spot. Refusing it would be refusing let. Witnessed by tests/04_self_place.pd.
    • The rule is about WRITES, not about assignments, so it covers the CALL path: invoking a method that declares &mut self ON self reaches the same fields and is refused wherever a direct write would be — from a &self receiver (tests/reject/call_mut_method_through_shared_receiver.pd) and from a by-value one (tests/reject/call_mut_method_through_by_value_receiver.pd). Stated separately because it does not follow from the place production: self.bump() is a call, not a place. Both directions still ALLOWED — &mut self calling &mut self, and &self calling &self — are executed by tests/04_self_place.pd.
    • *self is not a place and not an expression either: a reference receiver is already dereferenced on every field access, so *self asked for a second indirection and reached gcc as an indirection on a non-pointer. tests/reject/deref_self_is_not_a_place.pd.
  • implemented: bare nested blocks as statements — a { … } in statement position parses, as a side effect of blocks becoming expressions (N5-05). unimplemented: try { } blocks (N5-09).

  • implemented: break / continue, unlabeled. break MAY CARRY A VALUE, and only out of a loop used as one (N5-07): let x = loop { … break v; };. The two directions are both refused — a valued break out of a loop written for its effect has nowhere to put the value, and a VALUELESS break out of a value loop leaves the binding on the other side unwritten, which emitted an uninitialised C temporary and then read it until 1f64c32 refused it. continue is valueless in every position. (This bullet read "unlabeled, valueless" until N5-07.)

unsafe { } parses and src/unsafe_ops runs (src/driver/mod.rs:189-197), but raw pointer types and unsafe fn do not exist, so N12's restricted-unsafe is unimplemented. tests/11_unsafe_blocks.pd PASSES while only printing that.

A6.2 for loops

for i in 0..n { } — implemented. for x in arr { } — implemented, including where arr is a function parameter. Codegen used to emit sizeof(arr)/sizeof(arr[0]), the pointer size after array-to-pointer decay, so the loop silently visited 1 element (i64) or 2 (i32), and it hardcoded the element type as long long. The bound now comes from the declared length and the element type from the declared element type. A length codegen cannot resolve — a const generic, which N4 records as dropped — is a compile error on a parameter rather than a wrong bound, because a decayed pointer cannot supply the length at run time either.

A6.3 Expression forms

implemented: literals, identifiers, struct literals, array literals [a,b,c] and [v; n], indexing, field access, calls, enum construction, unary - ! & *, binary operators.

  • implemented: if, match, blocks and loop are EXPRESSIONS (N5-03/04/05/07). All four are read at the primary level (src/parser/mod.rs:4006-4018) and each reuses the statement parser it already had, reinterpreting the statements-plus-tail it returns as statements-plus-value. C has no expression with a block in it, so they lower by HOISTING: a temporary, a statement-form computation that assigns it, and a use of the name. GNU statement-expressions would say it in one line and are not available — the backend is whatever cc the host has. (This bullet read "unimplemented: they are statements, not expressions … a direct contradiction of N5" until 66dab38, f729cda.)
  • unimplemented: closures — no closure token path and no closure AST node.
  • implemented: tuple expressions and .0 indexing (N4-12). (a, b) builds a value and p.0 reads an element; the index is SYNTAX, read at compile time, because a tuple's elements may have different types and an index the compiler cannot read has no type to be. Two subsets, both refused by name: a tuple takes two or more elements ((e) is grouping, and a one-element (e,) would be a meaning decided by a trailing comma), and a chained index must be parenthesised — (p.0).1, because .0.1 lexes as one float literal ([0-9]+\.[0-9]+) and p.0.10 and p.0.1 both round-trip to 0.1, so the second index cannot be recovered without guessing.
  • implemented: as casts (N5-15), parsed between multiplication and unary (src/parser/mod.rs:3693-3694) so 10 / 4.0 as i64 is 10 / (4.0 as i64), and chainable. THE LEGAL SET IS NARROW BECAUSE THIS DOCUMENT DOES NOT SAY WHAT IT IS: N5 names as casts and the grammar gives the form, neither says which conversions are meant, so conversions among the numeric primitives and bool are implemented and every other cast is refused by name. A cast to bool emits ((x) != 0) rather than a C cast, because bool is C's int and (int)5 is 5. unimplemented: string interpolation.
  • implemented: ranges outside a for header (N5-14), including ..=. A range is a value — typedef struct { long long start; long long end; int inclusive; } __pd_range — with the end kept as written beside a flag rather than normalised to end + 1, which would wrap at the maximum. A range written IN a for header keeps its old fast path and builds no struct. (This bullet read "partial — codegen error 'Range expressions can only be used in for loops'" until ef74eba.)
  • partial: empty array literal [] — typeck cannot infer the element type (src/typeck/mod.rs:6216-6220, corrected from line 1874 of the pre-cleanup revision and again on 2026-08-25, when it had come to rest on a closing brace).

FIXED — the precedence bug: parse_multiplication parsed its RIGHT operand with parse_postfix rather than parse_unary, so the left side descended through the unary level and the right side could not, and a * -b did not parse. It now parses both sides through the cast level, which is parse_unary plus the as suffix (src/parser/mod.rs:3781-3781). Every other level of the ladder was already symmetric, which is why this was the only expression that failed. N5 requires a * -b; ef74eba delivered it.

A6.4 Method calls

implemented (N5-17, 4690ef0). x.f(a) parses as a call whose callee is a field access, and both the type checker (src/typeck/mod.rs:4001) and code generation (src/codegen/mod.rs:5976-5979) REWRITE it into the path call it means — TypeOfX::f(x, a) — rather than checking and emitting it as a second kind of call. The receiver becomes the first argument and is evaluated exactly once, and its position among the arguments is the one the source wrote: being the first argument, it is read first. That is not C's doing — C leaves the order of a call's arguments unspecified. It is N13's N13-03, whose contract is stated over READS and not over arguments: in a call with at least one effectful argument, every argument read happens at that argument's own source position. A value argument, receiver included, is read into a temporary, and the temporaries are declared in source order; a place argument has its POINTER taken at its own position, which is what orders an effectful subscript. FOUR shapes get no temporary, and only the first two are exempt by proof. An argument passed as the address of a bare name (a mut parameter) is the address of a fixed object, so there is no read to order; a by-value bare name of ARRAY type decays to a pointer to storage that already exists, so again what is read is an address and not a value. The third is an argument whose C type this backend cannot name — an EFFECTFUL one is refused outright, and a PURE one is emitted inside the call, where its read lands after every hoisted read rather than at its own position. That case is a residual, not a guarantee; no source is known to reach it, and the refusal beside it is what keeps it from mattering silently. The fourth is narrower still and is recorded because it was found by counting rather than by reading: an argument whose inferred C type is void or empty is also emitted in place, which no call can reach today because a void argument has no type to pass — it is a guard on the inference, not a case of the rule. tests/03_arg_evaluation_order.pd section 5 pins what the rule buys for a receiver, the whole contract is measured in A11, and the emitted shape it rests on is pinned by test_an_effectful_method_receiver_is_read_first_and_once.

THREE THINGS WERE BROKEN, AND THIS SECTION KNEW ABOUT ONE. It said x.f() was refused with "Indirect function calls not yet supported", which was true. It also said, twice — here and in A4.5 — to call methods as Type::method(receiver, args) instead. That did not work either, and had not: the parser builds every A::b(...) as an enum constructor, so a struct on the left produced "Undefined enum type: Rect". The recommended workaround for an unimplemented feature was itself unimplemented, and nothing measured it because no fixture used it. The third was Self — see A4.5.

The rule that separates a constructor from a path call cannot live in the parser, which has no types: it is an enum constructor if and only if the name is an enum's, asked of the enum table by the type checker and again by code generation, each stating it where it applies it.

unimplemented: the &self receiver. fn area(&self) emits a const struct Rectangle* parameter while the call site passes the value, and the C compiler refuses our own output with "take the address with &". Auto-referencing a receiver needs a real reference type; the debt is test_struct_with_methods in tests/rust-debt-manifest.txt, owned by M4.

(v0.2 also claimed a "same guard" in codegen at line 1870 of the pre-cleanup revision. grep -n 'Indirect function calls' src/codegen/mod.rs returns nothing; there is no such guard in codegen at any line. Claim withdrawn — and written without a citation form on purpose, so the gate does not pin a line this document is calling wrong.)

A6.5 Question mark, async and await

unimplemented — rejected, not lowered. (This section previously read "partial — silent breakage", describing C that referenced an undefined struct Result layout and a poll member nothing generated. Defect D5 was fixed on main in commit 439b241; both are now refused at typecheck. The silent-breakage description is retracted.)

Two bullets stood here restating the retracted description in the PRESENT tense — "? generates C that references a struct Result layout", ".await emits while (!<tmp>.poll(&<tmp>)) { }" — three lines after the retraction above and thirty before "What they used to do" below said the same thing in the past tense. A reader could not tell which paragraph described the compiler. They are deleted, not repointed: their line citations had drifted onto an enum-variant lookup and a bare )); respectively, so they were not evidence for the claim either way.

error: the `?` operator is not implemented
  --> prog.pd:11:28
11 |     let v: i64 = might_fail(x)?;
   |                            ^~~~
  = note: code generation has no lowering of `?` onto the enum representation it emits,
          and would instead produce C for a `struct Result { int is_ok; union … }` layout
          that no enum is ever generated as
  = help: there is no error-propagation operator; return the value and dispatch on it
          with `match`. Only non-generic enums are compiled, so declare a concrete one
          such as `enum Result { Ok(i64), Err(i64) }` — `Result<T, E>` will not compile

Note what is not claimed: Result is not a missing type — you can declare one, and before this refusal existed that is how a program reached code generation. What is missing is the lowering onto the representation enums actually get.

The refusal fires on the operator itself, before the operand is examined, so 3? and unknown()? reach it too. The wording is therefore phrased for any operand: it does not assert that what precedes ? is a Result, because in those programs it is not.

The match alternative is bounded, and the help says where it stops rather than leaving it to be discovered. Measured: dispatch works, propagation out of a helper works, payload types other than i64 work — but a generic Result<T, E> does not compile, because code generation skips generic enum definitions (src/codegen/mod.rs:2254-2258, src/codegen/mod.rs:2224-2228, src/codegen/mod.rs:2118-2120) and generic enum construction infers only the parameters a variant mentions, so Result::Err(e) yields Result<(), E>. One syntactic trap is worth stating: a match arm that is a block must not be followed by a comma, and propagation needs block arms because return is not an expression.

The refusal is raised by the type checker (? at src/typeck/mod.rs:4976-4976, .await at src/typeck/mod.rs:4983-4983) and again by code generation (? at src/codegen/mod.rs:6533-6537, .await at src/codegen/mod.rs:6545-6549), which is callable on its own.

What they used to do:

  • ? emitted C referencing a struct Result { int is_ok; union {…} data; } layout that no other part of codegen emits — enums are generated with a .tag field and __Enum__Variant constants instead. gcc reported variable has incomplete type 'struct Result'.
  • .await emitted while (!f.poll(&f)) {}. C has no member function calls, and the poll routine that was generated was the free function <name>_poll, which that call never named. That generator is deleted too — an async fn is refused at generate_function_with_name — so no live line carries this claim and it is written without a citation form, as A6.4's withdrawn claim is. There is no async runtime.

Both lowerings are deleted rather than kept behind a flag: they encoded a representation a real implementation must not reuse, and version control holds them.

The LLVM backend WAS a sharper case, and this paragraph used to describe it in the present tense while §A6 two hundred lines up already described the repair. Its expression lowering had no arm for either node: a catch-all returned the constant 0 for Question, Await, EnumConstructor and MacroInvocation alike, which compiled and was wrong. That catch-all is gone — each of the four is its own refusal now (src/codegen/llvm_text_backend.rs:1479-1492), and the type checker refuses before a backend is chosen besides, which is what tests/d5_unimplemented_constructs.rs pins. The deleted catch-all is described without a citation form, because there is no live line to cite.

async fn still declares fine; only .await is rejected, and it is rejected on any operand — some_variable.await as much as a call. Historically the only shape that reached code generation was a plain function declared -> Future<T>, because a call to an async fn is typed as its bare return type and so awaiting one never type checked; that is a fact about the old type rules, which no longer gate anything. The workaround is phrased conditionally for the same reason. Where a -> Future<T> signature is involved, note that deleting .await alone leaves a Future<T> where a T is required, so the signature has to change too.

Both are excluded from the bootstrap subset.

Under N7 the correct end state is not a working .await but no .await: the operator is not part of the language. Making it a hard compile error is a step toward the definition, not away from it. The full divergence list, including the ordering bug in effect propagation and the fact that impl methods are never effect-analysed, is in async-as-effect.md.

tests/09_effects_system.pd and tests/10_async_await.pd PASS conformance while only printing that the features are unimplemented.

A6.6 Tail expressions

fn add(a: i64, b: i64) -> i64 { a + b } — a function body ending in an expression rather than a return.

This is in the grammar (grammar.ebnf) and it previously compiled cleanly and returned garbage: the generated C was long long add(...) { (a + b); } with no return, and add(2,3) printed 6162934856. No error, no warning, wrong answer — the project's most dangerous defect class.

Two corrections to the previous version of this paragraph.

Retracted: the blast radius. It said "and every function in stdlib/ that ended in an expression was affected." That is false. Measured at abeb665, 0 of the 21 .pd files under stdlib/ compile — every one is rejected at lex or parse time, so nothing there was ever compiled and the defect cannot have lived there. The affected-stdlib/ claim was a counterfactual stated as a finding. A related over-correction is also retracted: an earlier phrasing that the resolver "never loads" the prelude was too strong. The resolver is live and reads $PALLADIUM_PATH (src/resolver/mod.rs:51-52); imports use import, not use. What holds is narrower and measured: stdlib/ is on no default search path, and forcing it on does not help — PALLADIUM_PATH=…/stdlib/std with import option; gives error: Unexpected token: expected 'fn' for method, found 'pub'. See A8 for packaging.

Corrected: "It is fixed" was half true. The parser lowers a tail expression to Stmt::Return, but not a tail if. Measured at abeb665:

fn fib(n: i64) -> i64 { if n <= 1 { n } else { fib(n - 1) + fib(n - 2) } }
fn main() { print_int(fib(10)); }

compiles clean and prints 8261746944 where the answer is 55. The generated C is

long long fib(long long n) {
    if ((n <= 1)) {
    n;
    } else {
    (fib((n - 1)) + fib((n - 2)));
    }
}

— bare expression statements, no return, in the single most idiomatic shape a recursive function takes. So D3 is open, not fixed, for every function whose body is a tail if. A tail expression in any other nested block is likewise not lowered.

The "437 affected sites" figure quoted elsewhere is an understatement, not an upper bound: the heuristic that produced it requires a bare expression immediately before the closing brace, and a tail-if function ends with the else block's }. A companion scan found 369 further sites of that shape. (Both counts are the stdlib unit's measurement, reproduced here by reference; this unit verified the fib reproduction and the generated C above directly.)

CLAUDE.md:66 records D3 as fixed. That is accurate only for the tail-expression case.

Regardless, write explicit return in every value-returning function. The bootstrap compiler does, which is why make selfhost is unaffected by any of this.

A7. Patterns

partial — the eight N6 rows M2 owns are satisfied; four pattern forms N6 names remain absent. src/ast/mod.rs:572 defines the variants:

pattern = pattern_primary { "|" pattern_primary } ;

pattern_primary = "_"
        | identifier [ "@" pattern_primary ]
        | literal
        | literal ( ".." | "..=" ) literal
        | "(" pattern "," pattern { "," pattern } ")"
        | path "::" identifier [ "(" pattern { "," pattern } ")"
                               | "{" [ identifier [ ":" pattern ] { "," … } [ "," ] ] "}" ] ;

implemented: literal patterns (1 =>, "s" =>, true =>, and -1 =>, whose minus the pattern parser reads as part of the literal) — N6-02; range patterns lo..hi and lo..=hi — N6-03; tuple patterns — N6-05; or-patterns A | B — N6-07; @ bindings — N6-08; arm guards if cond — N6-09; and FIELD SHORTHAND in a struct-variant pattern — Move { x, y } for Move { x: x, y: y }, where the field name is the binder. The shorthand is desugared at the point of parse, so the two spellings are the same PatternData::Struct value and no later pass distinguishes them (tests/m2_pattern_field_shorthand.rs asserts the equality of the two parses). A guard is checked inside the arm's own scope, after its bindings, so Num(n) if n > 5 can read n.

The shorthand reaches only that position, and its two boundaries are refused by DIFFERENT passes. A bare { x } is the PARSER's: a { may begin pattern content only inside path "::" identifier, so it is not a pattern at all and the parser stops at the brace with Expected pattern, but found '{' (tests/reject/brace_pattern_needs_a_variant_path.pd). A TUPLE variant written with braces is the TYPE CHECKER's: the parser ACCEPTS M::Pair { x, y }, because at that point it does not know what the path names, and the refusal is Pattern structure doesn't match variant M::Pair (tests/reject/field_shorthand_needs_a_struct_variant.pd). The explicit spelling M::Pair { x: a, y: b } is refused identically — shorthand adds no reach — which is a claim about a SECOND rejection and therefore carries its own fixture, tests/reject/tuple_variant_braces_explicit.pd.

The field list is a checked SUBSET, not the whole set, which predates the shorthand and is identical for the explicit form. Three claims, each citing what can actually witness it — a run fixture witnesses what the compiler ACCEPTS and can never witness a refusal, so the two refusals carry reject fixtures of their own:

  • Tolerated. A field the pattern OMITS is simply not bound: P::At { x } matches every At of a two-field variant, and P::At { } matches with no bindings at all while still covering the variant for exhaustiveness. The field sequence is optional and may end in a comma, which the production above now spells. There is no .. form; omission is spelled by leaving the field out. Stated because a reader arriving from a language whose field list must be exhaustive or end in .. will infer a refusal that does not happen. Witnessed by tests/06_field_shorthand.pd.
  • Refused — a field the variant does not have: Unknown field z in P::At, pinned by tests/reject/pattern_unknown_field.pd. Incompleteness is tolerated, wrongness is not; without this half, "subset" would be indistinguishable from "unchecked".
  • Refused — reading an omitted field in the arm body. The omission is silent at the PATTERN and not in the BODY: y was never introduced, and naming it is Undefined variable: 'y', pinned by tests/reject/pattern_omitted_field_is_unbound.pd. This is what makes "silently unbound" safe to write — without it the phrase could equally describe a language that leaves y holding garbage.

Two named SUBSETS of N6 and one rule this implementation OWNS, each pinned by a reject fixture rather than left to a reader. The subsets: a range's endpoints are integer or char literals where the normative production says expression — nothing in pattern position can be evaluated — both ends are required, and both must be the SAME kind, since ordering a scalar against an integer would need the conversion N4-04 forbids (tests/reject/range_pattern_mixed_endpoints.pd); char is ordered by code point, and a string endpoint stays refused because this language defines no order on strings; and a tuple pattern takes two or more elements, because (p) is grouping. The owned rule: an EMPTY range (5..=1, 3..3) is refused, which N6 does not ask for. An arm that can never be taken is a transposition far more often than a deliberate no-op, and the alternative is a silently dead arm; if the owner prefers the spec's literal reading, that is the rule to delete. An ALTERNATIVE MAY NOT BIND: P::Num(x) | P::Pair(x, _) is refused with x named, because every alternative would have to bind the same names at the same types for it to mean anything, and the arm is emitted as one || condition with no per-alternative site to assign from.

unimplemented: slice patterns (N6-06, owned by M3 — there is no slice type yet), non-enum struct patterns, ref/mut bindings, .. rest.

Exhaustiveness holds for every scrutinee type (N6-10). An enum must cover its variants; a bool is covered by true and false together; every other type needs an arm that matches every value — _, a binding, name @ <irrefutable>, an a | b with an irrefutable alternative, or a tuple of irrefutables (src/typeck/exhaustiveness.rs:114, src/typeck/mod.rs:5316). NO INTERVAL ARITHMETIC IS PROMISED: 0..=59 beside 60..=<i64 max> beside <i64 min>..=-1 covers every integer and is still refused, and the diagnostic says why rather than leaving the reader to infer it. A guarded arm counts toward nothing — whether it is taken is not decidable from the pattern.

Codegen lowers match to an if/else-if chain (src/codegen/mod.rs:4377-4379, src/codegen/mod.rs:4471-4476) whose final else TRAPS (N6-11): it prints no match arm was taken in <function> at line <n> to stderr and calls abort() (src/codegen/mod.rs:4498-4500). An arm carrying a guard cannot live in that chain — the guard needs a statement position after the bindings it reads, and a guard that FAILS must fall through to the next arm — so a match with any guard is emitted as a sequence of if (pattern) { … goto _match_endN; } ending in the same trap before the label (src/codegen/mod.rs:4413). The goto is what makes the fall-through path unconditional, which is what lets -Werror=return-type be armed in the shared gcc invocation.

With N6-10 enforced the trap is unreachable for a well-typed program, which is the point of it: it defends the gap between what the checker proves and what a process can hold. tests/n6_match.rs proves it behaviourally by corrupting an enum tag in the generated C, because the language itself offers no route to a fall-through.

Consequence: you can dispatch on an integer, a string or a bool with match.

A8. Builtins

partial. 34 builtins are registered — exactly the 34 that N14 defines — and all 34 can be called. Registry membership and callability are different claims and the earlier wording ("38 builtins exist and work") conflated them; file_flush and file_seek had never been callable at all until 2026-08-23, when their C wrappers were re-based onto the long long handle table. The generated table docs/reference/builtins.md is produced by scripts/gen-builtin-docs.py from src/builtins.rs and is the authoritative record of what pdc provides today; it is checked against the registry on every test run (src/builtins.rs::test_generated_builtin_reference_is_not_stale), which it was not when it went four names stale.

Measured against N14: the name sets are equal in both directionsnormative − implemented = none and implemented − normative = none — and that is a test, not a reading (src/builtins.rs::test_registry_is_exactly_the_normative_builtin_set). (Until 2026-08-23 there were 38, the extra four being file_open_ex, file_close_ex, file_read_ex and file_write_ex; see the reconciliation note under N14.)

One divergence remains, and two are closed:

  • Signatures — OPEN. Filesystem builtins return i64/bool handles rather than Result, because Result is not built in (A5.1); and string_char_at returns i64 rather than char, because char is not a type (A5). This is N14-03 and it belongs to M3, which is where Result arrives.
  • (CLOSED 2026-08-23 — file_flush and file_seek could not be compiled. Both were declared over an i64 handle here and over an opaque FileHandle (typedef void*) in the emitted C prelude, and file_seek's whence narrowed to uint8_t, so 256 arrived as 0. The type checker refused the calls rather than letting gcc fail on generated code. Their wrappers are now lowered onto __pd_file_handles, the long long table file_write and file_close already use. file_seek takes whence 0/1/2 and returns the new absolute position or -1, refusing any other whence rather than treating it as a seek; file_flush returns 1 or 0, its siblings' convention. Both are exercised by tests/stdlib/stdlib_builtins_file.pd.)
  • (CLOSED 2026-08-23 — dead C wrappers. __pd_file_open_ex, __pd_file_close_ex, __pd_file_read_ex and __pd_file_write_ex were still written into the prelude of every generated program although no builtin named them. They are deleted, and with them the FileHandle typedef, the FileMode enum and the six pd_file_* externs that only they used.)

N14's effect classification is unenforced, because effects gate nothing (A4.1).

Since 2026-08-21 there is one source of truth: src/builtins.rs. The type checker derives its signature table from it (src/typeck/mod.rs:1211-1211) and so does the borrow checker, which is what stopped the two from drifting apart. Codegen maps names to C symbols (src/codegen/mod.rs:6014-6014, corrected from line 1813–1851 of the pre-cleanup revision) and emits their C bodies inline into every output file (src/codegen/mod.rs:1659-1659, corrected from line 251–575 of the pre-cleanup revision).

(v0.2 described this as "two tables that must agree". That was true before src/builtins.rs became the SSOT; it is no longer the mechanism.)

Core: print(String), print_int(i64), panic(String)

String / char: string_len(String)->i64, string_concat(String,String)->String, string_eq(String,String)->bool, string_char_at(String,i64)->i64, string_substring(String,i64,i64)->String, string_from_char(i64)->String, string_to_int(String)->i64, int_to_string(i64)->String, char_is_digit(i64)->bool, char_is_alpha(i64)->bool, char_is_whitespace(i64)->bool

File I/O (handle = i64): file_open(String)->i64, file_read_all(i64)->String, file_read_line(i64)->String, file_write(i64,String)->bool, file_close(i64)->bool, file_exists(String)->bool, file_flush(i64)->i64 (1 ok, 0 fail), file_seek(i64,i64,i64)->i64 (whence 0=start, 1=current, 2=end; new position, or -1)

Paths and directories: path_exists, path_is_file, path_is_dir, create_dir, create_dir_all, remove_file, remove_dir, remove_dir_all

Whole-file helpers: read_file_to_string(String)->String, write_string_to_file(String,String)->i64

String also supports + for concatenation.

(An "Extended handle API" section stood here listing file_open_ex, file_close_ex, file_read_ex and file_write_ex. Those names left src/builtins.rs on 2026-08-23 and no Palladium program can name them; the section is deleted rather than marked, because a builtin listing is a list of what a program may call.)

The path and directory builtins are thin wrappers over extern symbols supplied at link time by runtime/palladium_runtime.c. Before that file existed, every one of these — and in fact every Palladium program — failed to link.

The standard library above them is unimplemented, and unshipped. Measured at abeb665:

  • 0 of 21 .pd files under stdlib/ compile. Each is rejected at lex or parse time; pdc compile stdlib/std/option.pd fails with Expected 'fn' for method, but found 'pub'.
  • It is not merely unreachable by default. The resolver is live and honours $PALLADIUM_PATH (src/resolver/mod.rs:51-52), but pointing it at the tree does not help: PALLADIUM_PATH=…/stdlib/std with import option; gives error: Unexpected token: expected 'fn' for method, found 'pub'.
  • It is not packaged. grep -rn stdlib .github/ returns 0 hits (exit 1), and neither Homebrew formula installs it — pdc.rb installs share/palladium/runtime, pdc-preview.rb installs lib/palladium/runtime, and neither names stdlib. (Formula paths are the stdlib unit's measurement of the tap; the .github grep is this unit's.)
  • scripts/conformance.sh:270 defaults its scope to tests and examples, so stdlib/ has never had a green row and its breakage was invisible.

A consequence worth stating plainly: because nothing under stdlib/ has ever compiled, no defect in the compiler can have "silently miscompiled the standard library". See A6.6, where exactly that claim is retracted.

A9. Memory model

partial. Ownership and borrowing are checked (src/ownership/borrow_checker.rs, 2268 lines) but not represented in the type system: the typechecker treats &T as T (src/typeck/mod.rs:744-748).

What the borrow checker actually enforces is a move/initialization discipline plus conflicting-borrow detection. A previous version of this annex asserted a defect here that does not exist; it is retracted and re-measured in A9.4.

(v0.2 said the checker "is currently stricter than the language needs in at least two measured cases (examples/practical/simple_sort.pd, tests/misc/test_vec_i64.pd both fail with "Conflicting borrows"). Re-measured at abeb665: test_vec_i64.pd now compiles, and simple_sort.pd fails with "Unsupported type in reference parameter", not a borrow error. The v0.2 sentence is retracted; the surviving borrow-checker defect is A9.3.)

N9 is unimplemented in full. ref is not a keyword; the implemented spelling is Rust's &/&mut with 'a parameter lists — the exact annotation burden the definition removes. fn f<'a>(x: &'a String) -> &'a String { return x; } compiles. Function.lifetime_params is parsed (src/parser/mod.rs:1295) and read nowhere outside test and LSP fixtures. There is no region inference: grep -rn 'region\|Region' src/ --include='*.rs' returns nothing.

No garbage collector. Strings are allocated from a 64 KiB static arena with a malloc fallback and are freed at exit (src/codegen/mod.rs:1624-1628, corrected from line 210–245 of the pre-cleanup revision).

A9.1 String is a copyable handle (decision, 2026-08-21)

String lowers to const char*, is allocated from the arena, and is never freed individuallygrep -c '__pd_free\|pd_free_string' src/codegen/mod.rs returns 0; the only release is __pd_cleanup_strings registered via atexit. There are no destructors and no drop glue.

Treating String as a move-only type therefore tracks an ownership that does not exist at runtime, and — decisively — cannot be worked around in the surface language: there is no clone, and &T is not a distinct type to the checker (A5), so with move semantics there is no syntax at all that reads a String twice out of an array slot or a struct field.

String is therefore a Copy type in the implementation. Passing it copies a pointer; nothing is duplicated and nothing is invalidated. Struct types (Type::Custom) remain move-only.

Tension. N12 defines String as an owned, heap-allocated value with move semantics and a destructor. The implementation contradicts that, and the contradiction is not a bug to be papered over: restoring the definition requires drop glue, per-value deallocation, and a real reference type in the checker, none of which exist. Until they do, this annex records the deviation rather than the specification adopting it. This is the one place in the document where an implementation decision was previously allowed to rewrite the definition; it is now recorded as a divergence instead.

A9.2 Array parameters

Every array parameter — [T; N], &[T; N] and &mut [T; N] alike — is passed as a pointer into the caller's array, because that is what C does to an array parameter. Nothing is copied, at any of the three spellings, so a write through any of them is visible to the caller.

Whether [T; N] parameters should copy or alias is not decided: §9 defines the memory model without mentioning array parameters, and §5 records that the typechecker cannot tell &T from T. Until that decision is made, code generation refuses the writes it cannot justify rather than picking one silently:

spellingmay write through itwhy
&mut [T; N]the declaration says so
mut xs: [T; N]the bootstrap subset's spelling for a mutable array parameter (bootstrap-subset.md §4)
&[T; N]❌ compile errora shared reference does not permit mutation; the C declarator also const-qualifies the element slot
[T; N]❌ compile errorthe write would reach the caller's array, which is the undecided semantics above

The rule is enforced on calls as well as assignments: a function may not pass an array it only holds shared, or by value, to a parameter that may write to it. Without that, the permission could be laundered one hop — fn f(xs: &[i64; 3]) { mutate(xs); } — and the write would happen under the callee's &mut binding, where it looks legitimate.

Supported element types for an array parameter are exactly i32, i64, u32, u64, bool, String and a struct/enum name — that is the BASE element, which the declarator resolves through the nested shape. Anything else is a compile error naming the type ("Unsupported array element type in function parameter"), not invalid C, and a function type never reaches here because §5 records that the parser refuses it ("expected type, found fn").

A nested array parameter ([[T; M]; N]) is emitted rather than refused: it is declared T name[N][M], and C decays that to a pointer to a ROW, so the callee reads the caller's object and &mut writes through to it (tests/02_types_nested_arrays.pd). Every dimension after the outermost must be a literal. An unresolved inner length — a const generic, an expression — is refused naming the parameter ("cannot declare the parameter …: the inner array length is written as …"), because an inner length is the stride of a row: [0] would compute wrong addresses silently, and the const generic's own name is not declared in the generated C (tests/reject/nested_array_param_inner_length.pd). The outermost length may be left open, since C throws it away anyway.

A mut parameter must be given something with storage. bump(1), retitle(make()) and bump(a + 1) are refused: a mut parameter receives a pointer to the caller's storage, and an rvalue has none — codegen emitted bump(&1) and gcc rejected the compiler's own output. The alternative, materialising a temporary, would require this specification to say what a write nobody can observe means; it does not, so the case is refused rather than invented. An argument that is storage but that the borrow checker cannot model as a place, such as xs[i] with a non-literal index, is checked against the mutability of the name it is rooted in.

Taking &mut x of a binding that was not declared mut is a borrow-check error, whatever x is: array, scalar or String. The same check applies to passing a binding to a mut x: T parameter, which writes through its pointer identically — codegen emits every mut parameter as a pointer to the caller's storage, so fn bump(mut x: i64) mutates its caller's variable exactly as &mut i64 does. Both were previously unchecked: let v = [1, 2, 3]; set(&mut v); compiled and modified an immutable binding, and fn bump(mut x: i64) { x = 42; } called with an immutable let n = 1; printed 42.

The bindings this covers are every binder the grammar has — parameters, let, the for variable, and match patterns. for and match bindings are immutable (there is no mut form of either), and a name that reaches the check without having been registered by any binder is refused, not permitted: an invariant with a permissive default stops being an invariant the first time a binder is added and forgotten, which is precisely how for variables and match bindings slipped through.

What the rule actually covers

This is a bounded enforcement, not a reference-safety model, and the boundary is worth stating exactly, because a guard that reads as protection while having quiet gaps is worse than no guard.

Enforced, in code generation, for a call whose callee is a plain fn this compilation knows:

  • the argument is a name bound to an array in the current function — a local, or a parameter whose declared form is one of the four in the table above.

Refused, rather than assumed safe, because the capability cannot be established:

  • the callee's parameter list is unknown to this pass (any callee not in the function table), and an array is being passed;
  • the array argument is not a plain name this pass tracks — a struct field, an element of an array (grid[0]), a call result — and the parameter may write to it. An element is refused even when its array is a local the caller owns: letting it inherit the array's capability would be a more permissive rule than this one, and the rule stated here is the contract.

Not covered by this rule at all, and not claimed to be:

  • aliasing between two array arguments beyond what the borrow checker already rejects;
  • references to anything other than arrays: &T and &mut T are the same type to the type checker (§5), so a scalar reference carries no capability information here;
  • any guarantee that survives a construct the front end has not implemented.

The reason the rule lives in code generation, and is shaped as a refusal, is that there is no reference type in the type checker to carry the permission (§5). A complete model needs one — that is M4's work, not this rule's. Until then this rule buys exactly one property: an array write that reaches the caller can only come from a spelling that declared it.

The stale account that used to sit here

An orphan ## 10. Execution model section stood between A9.2 and A9.3 and gave a DIFFERENT account of the same current behaviour, measured at abeb665: that &mut [i64; 3] "does not compile: Unsupported type in reference parameter", that a bare [i64; 3] parameter mutates its caller "with no diagnostic", and that therefore the implementation was "wrong in both directions at once". Every one of those three was true before D9 and is false now; A9.2's table above is the measured behaviour. Re-measured: &mut [i64; 3] compiles and the write is caller-visible; a write through a bare [T; N] parameter is a code-generation error naming the undecided semantics; mut a: [T; N] compiles and writes through.

It is deleted rather than corrected because nothing in it was both true and unique: the open normative question and its Option A / Option B consequences are N12.1, which is where they belong and where they are stated at more length, and the interim rule it described is the table above. It was also a ## 10. heading inside ## A9, duplicating A10 — which is how a whole section came to be stale without any reader noticing it was there.

A9.3 &mut of an immutable local is refused (was: accepted)

N12 requires that &mut be takeable only of a mut binding, and the implementation now enforces it for every referent kind. The check is check_mutable_borrow_allowed (src/ownership/borrow_checker.rs:408-414), which reads the mutable_bindings map described in A9.2; a name no binder registered is refused rather than permitted.

struct S { x: i64 }
fn bump(s: &mut S) { s.x = 77; }
fn main() { let v: S = S { x: 1 }; bump(&mut v); print_int(v.x); }

is refused with "cannot borrow v as mutable: it is not declared mutable". With let mut v it compiles, links and prints 77.

Historical. This section asserted the opposite — that the program above "compiles, links, and prints 77 — an immutable local mutated, with no diagnostic", measured at abeb665 — and that the defect reproduced for struct referents while not reproducing for arrays. Both halves are obsolete: the mutability check landed with the array-parameter work above, and re-measured on this tree the struct case is refused and so is the array case.

What is still true from the old scope note, and is a different defect: &mut i64 is not a working spelling. fn bump(s: &mut i64) { *s = 77; } reaches gcc, which rejects the compiler's own output with "indirection requires pointer operand ('long long' invalid)". That is a code-generation gap in scalar references, not a mutability-checking one.

A9.4 Defect D6, retracted

A previous version of this annex, and of feature-index.toml, stated that a call argument is borrowed as Lifetime::Named("fn") and released against Lifetime::Scope(n), so the borrow is never released and a value cannot be passed twice. That claim cited a line of src/ownership/borrow_checker.rs whose content is at src/ownership/borrow_checker.rs:83 today. The old number is deliberately not repeated here: a bare path:line naming a revision this tree no longer has is unpinnable, and an unpinnable citation cannot be told from one that has silently drifted.

The claim is false and the citation was wrong. src/ownership/borrow_checker.rs:599 is ReturnOwnership::Borrowed(Lifetime::Named("fn".to_string())) — the ownership classification for a function's borrowed return value, which has nothing to do with argument lifetimes. The citation had a green fingerprint the whole time, which is exactly the gate's limit: a pin proves a line has not moved, never that it supports the claim.

Re-measured from scratch at abeb665:

ProgramResult
t(s); t(s); — same String passed to two separate callsaccepted, prints 5 5
take2(s, s) — same String twice in one callaccepted, prints 10
s1(v); s1(v); — same array to two separate callsaccepted, prints 1 1
bump(&mut p); bump(&mut p); — successive mutable borrowsaccepted, prints 2
print(p.name); f(p.name); p.n — field to a builtin, then reusedaccepted, prints abc 3 1

None of D6's symptoms reproduce. The call path creates a per-call lifetime and ends its borrows when the call finishes: src/ownership/borrow_checker.rs:982 (let call_lifetime = self.context.new_lifetime();) and src/ownership/borrow_checker.rs:988 (self.context.end_borrows(&call_lifetime);), with the contract stated at src/ownership/borrow_checker.rs:84-86 — "the caller-side borrow always lasts exactly for the call expression".

D6 was fixed in commit 191f8c1 ("fix(compiler): five defects that made the language unusable", 2026-08-21), twelve commits before abeb665. Its message says so directly: "D6 call arguments were borrowed forever. Argument borrows were tagged Lifetime::Named("fn") while release only removed Lifetime::Scope(n), so a value could never be passed to two functions. Borrows now end with the call." The description was accurate about the original defect and was carried forward into documentation written after the fix landed.

Two rejections do still occur, and both are correct rather than defects:

  • take2(p, p) where p is a structUse of moved value: p. Struct parameters are moves (src/ownership/borrow_checker.rs:69-70), so this is move semantics working.
  • sum2(v, v) with two mut [i64; 3] parameters — Conflicting borrows. A mut array parameter is a mutable borrow (src/ownership/borrow_checker.rs:563-565), so passing the same array as two simultaneous mutable borrows is refused. This is expected under the current aliasing convention, not unconditionally correct: it follows from Option B's reading of N12.1, which is still open. Under Option A a [T; N] parameter would be a value, sum2(v, v) would pass two independent copies, and refusing it would be a bug. The struct rejection above needs no such qualification — moves are settled.

Action outside this repository's documentation: the project's CLAUDE.md lists D6 under "남은 결함 (열림)" — remaining open defects. That is stale by twelve commits and should be moved to the fixed list. This unit did not edit CLAUDE.md.

A10. Execution model

implemented. Execution starts at fn main. Arguments are evaluated left to right. The driver requires a main function; a library module without one cannot be compiled standalone — which is why scripts/conformance.sh reports SKIP_NO_MAIN for two files rather than failing them.

A11. Conformance

scripts/conformance.sh compiles, links, and runs every .pd under tests/ and examples/ against tests/conformance-manifest.txt, a closed inventory declaring what each fixture is expected to do. Current status, re-measured on the tree integrating feat/m2-xfail-six (2026-08-31):

verified 85 · untranscribed 0 · vacuous 6 · xfail 6 · reject 122 · skip 2 · failures 0, over 221 fixtures. (The su2 round of feat/m2-xfail-six added five: tests/04_self_place.pd, the first fixture in which a method taking a reference receiver links at all, and four rejects for the writes through a receiver the type checker refuses — through &self, through a by-value self, self as an assignment target and *self as a place. Its review round added two more for the same rule reached through a CALL: a &self and a by-value-self method each invoking a &mut self method on self. A further review round added one more, for a reference parameter handed a temporary. su3 added three: a chained receiver, and the two halves of the let production — the mandatory initialiser and the absence of let patterns — which had been stated here and never executed. Its review round added five xfails, one per distinct type-alias resolution failure, all owned by M3. The char-pattern round added four more: char literals became patterns, with three rejects for the boundaries. The round-3 review of feat/m2-items added the two rejects that pin the << branches the count-range fixture beside them never covered: 1 << 63, whose shift AMOUNT is legal and whose VALUE is not, and (0 - 1) << 3, a negative left operand C leaves undefined however small the result — so reverting either guard alone now fails a fixture of its own. Issue #42's first unit added tests/03_const_items.pd and tests/03_static_items.pd for N3-09/N3-10 with five refusals beside them, tests/reject/missing_return.pd for N3-03, and turned tests/02_types_enums.pd from a vacuous row — seven print calls announcing that enums were unimplemented — into a run fixture that constructs and destructures a unit, a tuple and a struct variant. That transition is where verified gains its third and vacuous loses one. Issue #41 — the pattern forms, exhaustiveness and the trap, plus tuples as values — added eight run fixtures and TWENTY rejects, nine of them from the review round that followed: an i64::MIN bound that inverted its own comparison, a payload the type checker never looked inside, a nested tuple literal whose struct was defined after the one that used it, and six shapes nobody had pinned. The ratio is the point: most of what a pattern feature adds to a language is a set of shapes it must refuse, and each of those is a program somebody would otherwise have written and had accepted. Before it, review round 2 added one run fixture — a macro expanded in a value position — and TWO more rejects: a generic method reached through its path form, and a generic enum's constructor. Both were accepted by the front end and unbuildable by the C backend, so both became refusals. Round 1 before it grew reject by SEVEN, which is the shape a review round leaves: external review of the N5 work found two miscompiles and three places where the front end approved C the backend could not build, and the repairs for the second kind are REFUSALS — each one a program that should never have been accepted, each pinned by a reject fixture. Before that round the figure was 64 verified, reject 21, over 95, and reject had FALLEN while the corpus grew — the only revision of this figure that ever did: feat/m2-expressions added eleven run fixtures, one per N5 row it closed, and transitioned tests/reject/loop_keyword.pd from reject to run, that fixture having asserted there was no loop keyword until N5-07 gave the language one. The figure before those — 52 verified, reject 22, over 84 — was taken before that branch. The one before it — 51 verified, reject 22, over 83 — was taken before feat/m2-witness-json added tests/witness/json_parser.pd, the second witness program thesis condition 4 names; it is one run row, so only verified and the corpus size move. The one before that — 48 verified, reject 16, over 74 — was taken before fix/m2-lexical landed the N2-03/04/08/09/10/11 lexical rows: three run fixtures (01_lexical_escapes, 02_types_chars, 02_types_floats) and five reject fixtures (unknown_attribute, attribute_with_args, attribute_inner, unknown_escape, unterminated_block_comment), which is 51 verified, reject 21, over 82; the single reject row between that and 83 is tests/reject/zero_length_array_self_reference.pd (N4-23). The one before that — 48 verified, reject 15, over 73 — was taken before fix/m2-async-producer added tests/reject/async_producer.pd, the N7-18 repro, which is a reject and moves both counts by one. The one before that — 46 verified, reject 14, over 70 — was taken before fix/d3b-tail-if landed 3 fixtures and closed D3b, which moved its defect fixture into verified. The figure before that — 43 verified, reject 0, over 53 — was taken before 17 rows landed, 14 of them reject. A11 is the authority a release plan reads, so a stale number here is release governance and not a documentation nit; that sentence is this file's own, and it is why the figure is re-measured rather than left.)

(This paragraph previously read "verified 33 · … · xfail 2 · skip 2 · failures 0, over 44 fixtures" and, below, listed "the three failures". Both were true before M1 and false when M1 shipped: the corpus gained the nine tests/stdlib/ drivers and tests/regression/ rows, D9 made examples/practical/simple_sort.pd run, tests/misc/test1.pd was transitioned, and the run has failures=0. The annex is the authority a release plan reads, so stale numbers here are release governance and not a documentation nit.)

scripts/check-docs.sh does the same for documentation snippets, and scripts/selfhost.sh checks the self-hosting fixed point.

Each fixture declares a class:

  • run — must compile, link, run, and have its stdout diffed byte-for-byte against a sibling .expected transcript. There is no exit-code-only spelling: a missing C return is undefined behaviour, so a tail-return miscompile (defect D3) prints garbage and still exits 0, and an exit-code verdict cannot see a wrong answer.
  • untranscribed — ran, but carries no transcript. The reviewed allowance for a fixture that genuinely cannot have one; it needs an owner and a why: reason and is reported as a debt on every run, so "no transcript" is a written decision rather than a default. Currently zero. (The owner field is an editable label; the authorisation boundary is review of the manifest, not the runner, which cannot distinguish an honest reclassification from an evasive one.)
  • vacuous — runs, but only prints that its feature is unimplemented. Its note must name the feature it fails to cover. ⚠️ Seven files are in this state: 02_types_enums, 07_traits_basic, 08_generics_basic, 09_effects_system, 10_async_await, 11_unsafe_blocks and 12_modules_imports. A green conformance run is not evidence that enums, traits, generics, effects, async, unsafe or modules work. They do not (§4.4, §5).
  • xfail — a known failure pinned to a stage and a diagnostic fingerprint. Failing at a different stage, or with a different message, fails the gate, so a fresh bug cannot hide behind an old excuse. A listed program that starts passing is XPASS and fails the gate.
  • reject — a negative test: the compiler must refuse it with the declared diagnostic. This is real coverage, and it is how "the compiler rejects .await" gets tested instead of a program that prints prose about async being unimplemented. CLOSED: reject=14 on the integrated tree. This paragraph carried a universally-quantified absence — "No fixture uses this class" — that was false the moment the rows landed, and an absence claim is the kind that stays wrong quietly, because nothing about a passing run contradicts it. The refusals a second implementation must reproduce are now in the corpus rather than only in Rust integration tests.
  • skip — a declared non-program, and it must PROVE that: the compiler has to refuse it at a declared stage with a declared diagnostic, exactly like an xfail. This replaced an fn main regex, which fn /* c */ main(), fn // c + newline + main(), and plain fn + newline + main() all evaded while compiling and running fine — a real program could be declared skip and never gated.

Because the inventory is closed, a fixture that is deleted, renamed, or added without a declaration fails the gate rather than silently shrinking or growing it. The gate's own ability to fail is tested by make test-conformance-runner (133 cases).

There are no failures. The one remaining xfail is tests/projects/hello_pdm/tests/test_math.pd ("Undefined function: add"), which needs cross-file module imports. (A previous version of this paragraph listed three failures. Two of them are gone: examples/practical/simple_sort.pd runs since D9 was fixed, and tests/misc/test1.pd was transitioned in the same change.)

Seven files in tests/ named after a feature do not exercise it. 02_types_enums.pd, 07_traits_basic.pd, 08_generics_basic.pd, 09_effects_system.pd, 10_async_await.pd, 11_unsafe_blocks.pd and 12_modules_imports.pd each only print a message saying the feature is unimplemented, and pass trivially. A green conformance run is therefore not evidence for enums, traits, generics, effects, async, unsafe enforcement, or modules. (v0.2 named two of these; a later revision said six and omitted 02_types_enums.pd, which the manifest has always declared vacuous.)

A12. Relationship to the bootstrap subset

bootstrap-subset.md defines PBS-1, the subset in which the self-hosting compiler is written and which that compiler implements. PBS-1 is deliberately smaller than what pdc accepts: it excludes every partial construct in this annex.

make selfhost reaches a byte-identical fixed point, which makes PBS-1 the one part of this document where definition and implementation coincide.