Envzn Language Constitution — Version 2.0
Version: 0.4 (Draft — Article ▸ Section ▸ Part ▸ Paragraph outline)
Status: Restructured 2026-06-14 into a numbered Article ▸ Section ▸ Part outline for stable cross-referencing (e.g. I.J.ii.c). Narrowed 2026-07-28 to the three articles that specify the language: the non-normative Developer Guide moved to ENVZN_USER_GUIDE.md, the roadmap of unshipped work was removed to a separate document that this Constitution does not depend on, and the grammar — formerly Article V — became Article III. Apart from that renumbering, labels are append-only and are not reused. One label anomaly is deliberate and predates this document's current form: under I.J.i the source carried two paragraphs both labelled (o), and the 2026-06-14 restructure resolved the collision by relabelling them (s) and (t), leaving (r) unused. The sequence there runs a–q, s, t; the gap is not a missing paragraph.
Last Updated: 2026-07-28
Author: Brian Haughton
About this document
This Constitution is the reference description of the Envzn programming language. It is a working document rather than a finished standard, and it is written with the understanding that a language under active construction will always describe a little more than it has so far built. Two earlier habits made the document harder to use than it needed to be. The language was specified once and then partially re-specified a second time under a different heading, so a reader chasing a single rule could find two accounts of it that no longer agreed. And the material that describes what Envzn is sat in the same flat sequence as the material that describes how the current compiler implements it, with no signal telling the reader which one they were standing in. This rework separates those concerns deliberately.
The document is now organized into three articles, and the distinction between them is the most important thing to understand before reading further:
-
Article I — The Envzn Language is the normative specification. It describes the language as conceived: its purpose, its principles, its definitions, and every one of its constructs, laid out in the ordinary categories a compiler text would use — types, expressions, statements, control flow, memory, and so on. Article I is implementation-independent. It says what a correct Envzn program means, not how any particular compiler produces it. Where Article I and any other article disagree, Article I is authoritative.
-
Article II — The Envzn Intermediate Representation and its Lowering indexes Envzn's own intermediate representation, the EvIR. It summarises the EvIR's fourteen node groups and points into the companion
ENVZN_IR_SPEC.md, which carries the full node specification, the C++ lowering of every node, and the lowering model. That companion is generated from the compiler's own spec-trace and kept in sync with this constitution; like the lowering it records, it describes current behavior and is expected to change as the compiler does. -
Article III — Grammar (EBNF) is the normative concrete grammar, consolidating in one place the lexical and syntactic rules stated in prose across Article I. It is a formalization of Article I, not a second authority: where the grammar and Article I disagree, Article I wins and the grammar is the defect. It is built in tranches; every not-yet-expanded non-terminal is flagged so its coverage is never silently overstated.
Which document wins. The order of authority is fixed, and it is worth having in one place. Article I governs what a program means; where it and anything else disagree, Article I decides. Article III is normative for concrete syntax and formalizes Article I — where the grammar and the prose diverge, the grammar is the defect. Article II is descriptive rather than normative: it summarises how the EvIR lowers today, and where it and the generated ENVZN_IR_SPEC.md disagree, the specification wins, because it is anchored to the compiler's own trace. ENVZN_USER_GUIDE.md is non-normative throughout and constrains nothing.
Two divisions that this document formerly carried have been moved out, and their section labels are preserved unchanged so existing cross-references still resolve. The former Article III — The Developer Guide (non-normative idioms, code style, and testing practice, labelled III.A–III.C) is now ENVZN_USER_GUIDE.md; references of the form III.C are written here as ENVZN_USER_GUIDE.md III.C to distinguish them from the grammar's III.0–III.7. The former Article IV — Roadmap and Pending Surface recorded work that was specified but not shipped; it is maintained separately and is not part of this Constitution's closed set. Where a construct here is designed but not yet available, this document says so in place rather than referring out. Neither companion is normative, and nothing in this Constitution depends on either for a rule.
Two conventions run through the whole document. Normative statements — the rules a conforming implementation must obey — are written as plain declarative prose; where a rule names a specific compiler behavior, it says so. Informative statements — rationale, examples, and cross-references — explain rather than constrain, and are marked as such when the distinction matters. Throughout, code blocks show Envzn source unless they are explicitly labelled as emitted C++ or as shell commands.
Table of Contents
Article I — The Envzn Language (normative)
- I.A — Purpose, Character, Principles, and Tenets
- I.B — Definitions and Notation
- I.C — Lexical Forms
- I.D — The Type System
- I.E — Declarations and Bindings
- I.F — Expressions
- I.G — Statements and Assignment
- I.H — Control Flow
- I.I — Memory and Ownership
- I.J — Aggregates and Iteration
- I.K — Abstractions
- I.L — Concurrency
- I.M — Error Handling
- I.N — Modules and Linkage
- I.O — The Standard Library Surface
Article II — The Envzn Intermediate Representation and its Lowering (spec; full node/lowering tables in ENVZN_IR_SPEC.md)
- II.A — Literals and constants
- II.B — References and access paths
- II.C — Operators and conversions
- II.D — Calls, construction, and lambdas
- II.E — Bindings and assignment
- II.F — Control flow
- II.G — Returns and expression statements
- II.H — Recoverable failure and presence
- II.I — Unrecoverable failure and assertions
- II.J — Concurrency
- II.K — Program structure
- II.L — Foreign and unsafe
- II.M — Debug instrumentation
- II.N — Types and lifetime
Article III — Grammar (EBNF) (normative concrete grammar; formalizes Article I)
- III.0 — Notation
- III.1 — Lexical grammar
- III.2 — Source file and module structure
- III.3 — Declarations, bindings, and types
- III.4 — Control flow
- III.5 — Expressions
- III.6 — Type, member, and conversion declarations
- III.7 — Coverage ledger
Appendix — Reserved Identifiers Catalog
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Article I — The Envzn Language
The normative specification of Envzn. This article describes the language as conceived — its purpose and character, then every construct in the ordinary categories a compiler text uses: types, declarations, expressions, statements, control flow, memory, aggregates, abstractions, concurrency, errors, modules, and the standard-library surface. It is implementation-independent: it says what a correct Envzn program means, not how any particular compiler produces it. Where this article and any other disagree, this article is authoritative.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Section I.A — Purpose, Character, Principles, and Tenets
Establishes why Envzn exists and the standard every design decision is judged against: the six character traits, the fixed tiebreak that makes memory-safety the floor and otherwise relocates cost inward, and the principles and growth-commitments a proposed feature is obeyed-or-rejected by.
(a) Envzn (pronounced "envision") is a compiled, statically typed, object-oriented language for building robust application software. It rests on a deliberate premise: that a single language can carry the full weight of modern systems work — ownership, concurrency, a real module system, a typed foreign-function boundary — while keeping a surface plain enough that a reader new to the language can still follow a program. Complexity in Envzn is not eliminated, because it cannot be; it is relocated. The difficult machinery lives inside the compiler, the kernel, and the standard library, where it is written once and audited carefully, so that the code a developer writes from one day to the next stays close to ordinary description rather than ceremony.
(b) That premise produces a particular set of trade-offs, and it is worth being honest about them at the outset. Envzn prefers an English keyword to a punctuation symbol almost everywhere the choice arises. Code is read far more often than written, and a reader should not need an operator-precedence table in working memory to scan a function. It prefers one obvious way to express a thing over several interchangeable ways, accepting that an experienced developer will occasionally find the one obvious way slightly longer than a terser alternative would have been. And it resolves questions of memory safety through a compile-time ownership model rather than a garbage collector, accepting a stricter set of rules at authoring time in exchange for predictable destruction and no runtime collection pauses.
(c) Beneath that premise sit six traits that describe the character the language is reaching for. They are not constructs and they are not rules a compiler can check; they are the qualities Envzn is meant to exhibit when it is working as intended, and together they are the standard against which any proposed change is weighed. When a question about the language is genuinely open — when two reasonable designs present themselves and the only thing left to decide is which is the more Envzn of the two — these six are the terms the argument is settled in.
- Memory safe. A correct Envzn program neither leaks nor crashes on a memory fault. Every allocation is owned, destruction happens at a point the source determines, and the patterns ownership cannot prove safe are refused at compile time rather than admitted and hoped over.
- Readable. Someone reading code they did not write can follow its logic. Control flow is visible on the page, and an expression's effect is local to the line that holds it. Nothing of consequence happens that the source does not show: no implicit conversion, no hidden allocation, no precedence rule to reconstruct from memory. The measure is deliberately demanding, and it is the founding one. The logic of a program should be followable by someone who does not yet know the language — simple enough, in the phrase the project has held from the start, that a child could read it. That holds even where a particular operator is vocabulary the reader must still learn.
- Modern. The language is built on the practices the field has already settled — ownership-based safety, result values for ordinary failure, exhaustive matching over closed sets, Unicode-correct text, sound hashing — rather than on the habits of the languages it descends from. It does not carry an idiom forward merely because a predecessor had one.
- Fast. Envzn is meant to run on par with idiomatic C++. Speed is designed in from the beginning — compilation ahead of time, ownership in place of reference counting, abstractions that lower to nothing at runtime — and is not treated as a pass to be bolted on once the language works.
- Intuitive. Someone writing Envzn finds that the natural guess is the correct one: the language behaves the way an experienced programmer expects, names mean what they appear to mean, and the path of least resistance is the path the language intends. Where readable is the experience of the person reading the code, intuitive is the experience of the person writing it.
- World-class. Set beside the languages a developer would realistically choose among — C++, Rust, Swift, Go — Envzn is a serious option rather than a teaching exercise. This trait is in part the sum of the other five, but it carries a discipline of its own: a feature is designed by studying how the strongest languages solve the same problem and then matching or bettering them, never in isolation.
(d) These six are held in balance rather than ranked, and most of the time a sound design serves all of them at once — finding the option that does is much of the craft of the language. But they can pull against one another: the fastest implementation of a thing is rarely its most readable form, and a check that makes a program safe is seldom free. When they conflict, the order of resolution is fixed. Memory safety is the floor, and it is never traded — a faster or simpler design that cannot be shown safe is rejected for that reason alone, exactly as the ownership principle below requires. Above that floor, the standing resolution is to relocate the cost inward. The intricate, fast, difficult machinery moves into the compiler, the kernel, and the standard library, written once and audited closely, so the surface a developer reads and writes stays readable and intuitive while the implementation beneath stays fast. This is the premise that opened the section, turned to use as a tie-breaker. Where even that does not decide, the conflict is settled case by case, in the open, against these six traits. There is no further mechanical rule; inventing one to dodge the judgment would serve the traits less well than making it.
(e) One tension, finally, the language does not try to resolve at all. A readable surface and the terse shortcut an experienced developer reaches for pull in opposite directions, and always will. The construct that reads most plainly on first encounter is rarely the most economical for someone who has written it a hundred times. Envzn does not pretend this tension away. It leans, by default, toward the reader: the one obvious way is the readable one, and power lives a step off the routine path rather than across it. An advanced developer will now and then find that path longer than a denser language would have made it. That cost is real, it is permanent, and the language pays it on purpose.
(f) The traits describe what the language is reaching for; the principles below are the fixed commitments that make the reaching possible. A trait is weighed, but a principle is obeyed — it is not a goal to be traded against a feature, and a proposed feature that violates one is rejected on that ground alone:
- Compiled ahead of time, never interpreted. Envzn source is translated to a native executable before it runs. There is no interpreter, no dynamic evaluation of source text, and no runtime that loads and executes Envzn code on the fly.
- Strong, static typing. Every value has a type known at compile time. Type inference is permitted only where the type is unambiguous from context; at every boundary — method signatures, class fields — the developer states the type explicitly.
- Memory safety through ownership. Every class instance has exactly one owner. Lifetimes are determined statically, destruction is deterministic, and the few patterns the ownership model cannot prove safe are rejected at compile time rather than permitted and checked later.
- Readability before brevity. Keywords are preferred over symbols wherever the keyword reads clearly. The language accepts a modest amount of extra typing as the price of code that scans without specialized knowledge.
- One obvious way. For most tasks there is a single idiomatic construct. Power features exist, but they are kept out of the path of routine code rather than placed across it.
- Modules map directly to folders. A module is a directory on disk. There is no import-resolution algorithm to learn and no search path to configure; the filesystem layout is the module graph.
- The language can express itself. Envzn is meant to write its own kernel and its own standard library without dropping into another language to do so. C++ is the current compilation target and a typed foreign-function boundary exists for reaching genuine platform code, but neither is a capacity the language leans on to cover a gap in its own expressiveness. A thing that can only be written in C++ is a gap in Envzn to be surfaced and closed, not a shortcut to be taken quietly.
- Bounded genericity. Generics serve one purpose — homogeneous, type-preserving containers and the uniform algorithms over them, in the cases where erasing the element to a common supertype would cost either static safety or runtime performance. A type parameter is always constrained to a stated contract (I.K.ix); heterogeneity and open extension are the work of interfaces. Generics never grow into a compile-time metalanguage: type-level computation, substitution-driven overload selection, and metaprogramming are out of scope by design. The restraint is deliberate, and it is where the readable and fast traits are kept from pulling apart: the container that lets them coexist is welcome, the metalanguage that made them collide elsewhere is refused.
(g) Beyond the principles, a set of commitments governs not what the language is but how it is allowed to grow, and they belong in its constitution because a language is only ever as world-class as the discipline that tends it. The first is that surface simplicity is structural and may not be spent down quietly. A feature that buys capability by adding syntax the routine reader must carry has moved cost from the implementer onto every future reader. That trade is made deliberately, with a stated reason. The second is that a shipped feature is world-class or it is diagnosed. A capability either works completely, or its limits are reported at compile time and carry a documented path to removal that the present design does not foreclose. There is no silent "good enough": a limitation a developer meets only by colliding with it is treated as a defect. The third is a commitment to honesty in the work itself. A change that quiets a visible symptom while leaving the real problem in place is rejected: it turns a problem that could be seen into one that cannot. A pragmatic shortcut is taken only when named and bounded, never when silent. The last is that the specification runs slightly ahead of the implementation on purpose. This Constitution describes Envzn as it is intended to be; the canonical compiler measures what exists today. Where the two diverge, the gap is work not yet overtaken rather than an error in the design. Article II records only what the compiler actually does, and is the place to confirm whether a construct is live. A construct described in Article I but marked here as not yet implemented is aspirational: part of the designed language, not yet part of what a developer can compile today.
Part I.A.i — The structure of an Envzn program
(a) An Envzn program is built up in layers, and it is worth seeing the whole shape of that composition before any single layer is examined in detail. At the top stand applications and libraries. An application is a program that runs; a library is one or more modules combined to provide some specific, reusable capability rather than to run on its own. Both an application and a library are composed of modules, and every module depends — always, and without having to declare it — on the Envzn kernel module, together with whatever other modules its particular work requires.
(b) The module is the unit of composition below the application or the library, and I.N describes it in full; what matters at this overview level is what a module contains. A module is composed primarily of classes, supported by interfaces, enumerations, and structs. The file layout of a module follows directly from that division of roles. Each class is placed in its own .ev file, named for the class it holds, so that the .ev files of a module enumerate its classes one for one. The supporting types are grouped by kind, each grouping file named for the kind of declaration it collects: every interface and group into interfaces.ev, every enumeration into enums.ev, structs into structs.ev. A struct closely tied to one type may instead take its own file beside it (I.N.g).
(c) A class is a named block, and it is composed of two kinds of member: its data fields, which hold primitive values and instances of other classes, and its methods, which carry the behavior. A class may inherit the methods of a single parent through EXTENDS, and it may implement zero or more interfaces through IMPLEMENTS. An interface, by contrast, is a contract rather than a container: it must declare at least one method, and it may not declare any data fields at all. The constructs that relate classes and interfaces to one another are the subject of I.K.
(c.i) Rule — no phantom computed fields. A class's data fields are exactly those it declares. The compiler synthesizes no computed or phantom data field, so a .name access always reads a declared data field and never silently dispatches to a method. A quantity a class exposes through behavior — a length, a size, a count derived from its contents — is a method, reached with ->name(), and is never also surfaced as a bare .name field. What the API declares is what a . access sees: no more, no less (I.B.b: . reads a data field, -> invokes a method — not interchangeable). Accessing .name where name is not a declared data field is a compile error (it does not fall through to a synthesized accessor), and where a same-named method exists the diagnostic points the reader at ->name().
(d) A method is itself a named block. It may take inputs and produce outputs. Between the two it is composed of its own local data fields — again, primitive values and instances of other classes — together with the control structures that set and manipulate both those and the enclosing class's data fields. Within a method, further blocks may appear, and they are of two kinds. A named block is introduced by a keyword — LAMBDA and SYNCHRONIZED are the examples — while an anonymous block is a bare pair of braces with no keyword at all. Every block, named or anonymous, opens with { and closes with }, and blocks may be nested one within another up to fifteen levels deep.
(e) The block is also the unit of storage lifetime. Local variables declared inside a block are placed on the stack as the block executes. When the block closes they are removed in the reverse of the order they were added: the last variable pushed is the first popped. This is the same last-in, first-out discipline that I.I describes for the destruction of owned values, and it is the mechanism by which a block's closing brace is also the end of its local variables' lives.
Part I.A.ii — The central guarantee and the boundary of its present proof
(a) The trait the language will not trade is memory safety, and the mechanism that discharges it is worth stating plainly in one place, together with an honest account of how far the current proof actually reaches. The guarantee has two halves. The first is single ownership. Every class instance has exactly one owner, and a move transfers that ownership and poisons the source. Escaping references the ownership proof cannot reach become reference-counted handles, and their combined graph of owning and strong edges must be acyclic. The absence of leaks is therefore a theorem the compiler discharges at compile time (I.I.i, I.I.v), not a behaviour a running program merely tends to exhibit. The second is aliasing discipline: for any one referent, at any one time, either any number of read-only references exist or exactly one writable reference does, never both, and no value may be mutated through another path while a reference to it is live. Together these are what the reference checker of I.I.v enforces, and what every other guarantee in the language rests on.
(b) That discipline is enforced today by a structural analysis: fixed rules over the shapes a program's references and fields may take, together with the acyclicity check over the owning-and-strong graph that discharges the no-leak theorem for the cases that graph can express. The structural layer reasons about one method body at a time. At every boundary where control leaves a body it relies on the callee's checked contract rather than the callee's code: whether a method mutates its receiver, whether a parameter is observed or consumed, whether a reference it hands back is read-only or writable. A call through dynamic dispatch is sound because the marker it trusts has already been checked on the other side. A call whose target monomorphization has made statically known is sound because the concrete body is then available to walk directly.
(c) The structural analysis is conservative, and in narrow cases its shape rules are at once too strict and too loose for the same structure. The guarantee's intended form is therefore not stricter shape rules but a different kind of analysis altogether. An Envzn program compiles whole-program to its own intermediate representation, with no signatures-only view of a dependency, so the compiler can follow every reference from creation to cleanup through the actual code. The intended memory-safety proof walks the bodies rather than inferring lifetimes from annotations. A cheap structural pass discharges the references whose safety is obvious, and a flow-sensitive walk runs the precise creation-to-last-use analysis only on the small residue that escapes into fields. This is the analysis that makes the language's central claim true: it can accept a program a signature-based checker must reject, because it sees a body that checker refuses to look at. It is implemented and enforcing — the walk runs after the intermediate representation is built and before any C++ is written, and a reference it proves dangling is rejected outright, so a build cannot proceed past it. What remains is a residue of edge cases the project intends to close: the escape shapes beyond a field store, and the whole-program tier that overlays a dependency's bodies rather than resting on its checked contract.
(d) Two boundaries stand outside the compile-time proof by construction, and the language marks both rather than hiding them. At the foreign-function boundary there is no Envzn code to analyze, so a foreign value is reached only inside an UNSAFE block whose every use is confined and greppable and where the guarantee is explicitly suspended rather than silently weakened (I.I.viii). Across threads the static checker reasons about sequential aliasing only. Concurrent safety is carried by a separate, structural mechanism: ownership transfers on a channel send, task bodies may not capture shared mutable state, and shared mutable state is reachable only through a monitor that serialises its own access (I.L). A data race is prevented by what the language forbids at compile time, not by an analysis of interleavings.
(e) The whole-program walk needs a dependency's bodies. The intended answer to distributing a library without its source: a compiled library publishes its intermediate representation with a contract inferred from those bodies and frozen at publication, against which the library is proved in isolation and every consumer checked. Whether that is the final shape of the package artifact is pending rather than settled, but it is a designed answer rather than an open void. This is the memory model held to the same standard as the rest of the document: what is proved is claimed, what is intended is labelled as intended, and the boundaries where the proof stops are named.
Section I.B — Definitions and Notation
Fixes the precise meaning of the foundational terms — handle, owning, EMPTY, STATUS, pipe-XOR, lowering — that the rest of the document relies on, and states the notational conventions (the . / -> sigils, the E#### codes) used throughout.
(a) The terms below carry precise meanings throughout this document, and several of them are used in ways that differ from their ordinary use in other languages. A reader who treats handle, owning, EMPTY, or pipe-XOR as loosely synonymous with a familiar concept from C++ or Swift will misread the rules that depend on them, so the definitions are given here once and relied upon everywhere after.
- Kernel. The core of the language, packaged as the module named
ENVZN. The terms kernel and ENVZN module are used interchangeably in prose;ENVZNis the precise name when the discussion turns to libraries, linking, or dependency resolution. The kernel is always available to every Envzn program without being declared as a dependency. - Module. A filesystem folder containing a manifest and one or more Envzn source files. A module is the unit of compilation, dependency, and visibility. It is never a single file.
- Class type. A type introduced by a
CLASS,ABSTRACT CLASS, orSINGLETON CLASSdeclaration, together withString, which is a built-in value type that follows class-type rules for assignment and absence. Class-typed values are heap-allocated and reached through a handle. - Handle. The stack-resident value through which a class instance is reached. A handle is owning or non-owning. An owning handle is responsible for the instance's destruction; a non-owning handle observes an instance owned elsewhere and never extends its lifetime.
- Owning / non-owning. An owning binding holds sole responsibility for the value it names and frees it when the binding's scope ends. A non-owning binding — introduced by the
REFERENCEkeyword — observes a value without responsibility for it. Ownership is decided by theREFERENCEkeyword alone, never by whether a value may be absent. - EMPTY. The single absence value in the language. A class-typed binding either holds an instance or is
EMPTY. There is no null, and there is no separate optional type: absence is a state a class-typed binding may occupy, not a wrapper applied to a type. Primitives,STATUSvalues, enum values, and struct values are neverEMPTY— they always hold a defined value. - STATUS. A built-in type carrying one of three outcomes —
SUCCESS,PARTIAL_SUCCESS, orFAILURE— together with an optional message and numeric code.STATUSis the language's vocabulary for recoverable failure and is distinct from the absence vocabulary built onEMPTY. - pipe-XOR. A return shape, written with the
|separator in aRETURNSclause, in which exactly one of the two declared sides is populated per call — the success side or the failure side, never both. The success side is a tuple of one or more typed value slots, all populated together on success; the failure side is a singleSTATUS. It is named for its exclusive-or semantics, to distinguish it from the comma-separated multi-return shape in which every slot is always populated. - IR category. One of the ordinary, implementation-independent categories into which a language's constructs are grouped — types, expressions, statements, control flow, and so on. Article I is organized by these categories. They are not the same thing as Envzn's own intermediate representation, the EvIR, whose node groups Article II specifies and which the categories here loosely mirror.
- Normative / informative. A normative statement is a rule a conforming implementation must obey. An informative statement — rationale, an example, a cross-reference — explains a rule without adding to it. Articles I and III are normative — Article I in prose, Article III as the concrete grammar that formalizes it. Article II is a reference description of current compiler behavior rather than a rule. The full order of authority is stated once, in About this document.
- Lowering. The translation of an Envzn construct into the C++ the canonical compiler emits for it. Lowering is described in Article II and is not part of the language definition.
(b) On notation: a fenced code block contains Envzn source unless it is explicitly introduced as emitted C++, as JSON, or as a shell command. Member access is written with two distinct sigils that are not interchangeable — . reads a data field and -> invokes a method — and examples rely on that distinction without re-explaining it each time. A diagnostic code in the form E#### names a specific compiler error; the codes are stable identifiers, and ENVZN_IR_SPEC.md C.viii describes how they are reported. Where a passage describes a construct that is specified but not yet implemented, it says so directly, at the point the construct is described.
Section I.C — Lexical Forms
The smallest units a tokenizer recognizes before any grammar applies: comments, the newline-terminated statement, the identifier rules, and the numeric, character, and string literals. These are the alphabet every later category is built from.
(a) Rule. Lexical forms are the smallest units of an Envzn program — the shapes a tokenizer recognizes before any grammar is applied. They are the alphabet from which every later category is built, and the language keeps them deliberately few and deliberately plain.
Rationale. Everything the grammar later assembles is spelled out of this fixed, small alphabet; keeping the alphabet plain keeps the whole surface readable, because a reader never has to decode an exotic token before they can parse a statement.
(b) Rule. Envzn has three comment forms. A // begins a single-line comment, and everything from the slashes to the end of the line is ignored. A /* opens a comment that runs, across as many lines as needed, until the matching */. A /// begins a documentation comment, which is retained rather than discarded and is the sole mechanism by which prose written in source reaches the generated API documentation. The first two forms may appear wherever whitespace may appear — at the end of a line of code, alone on their own line, or inside a block — and they carry no meaning to the compiler beyond their own removal.
A documentation comment is governed by four rules. First, a single optional space after the /// is consumed and is not part of the text. Second, consecutive documentation-comment lines form one run, and their text is joined with newlines. Third, a run attaches to the immediately following token that is not itself a documentation comment; where that token introduces a declaration — a class, interface, struct, enum, group, union, primitive, alias, method, initializer, or field — the text is preserved on that declaration, travels with it into the module's emitted .headers, and is therefore visible to consumers of the module as well as to the documentation generator. A run at end of file, attached to nothing, is discarded. Fourth, a documentation comment cannot change the meaning of a program: it is removed before parsing, and no emitted output depends on it.
The form is deliberately the one Rust and C# already use, rather than a spelling invented here. A declaration's description belongs immediately above the declaration it describes, where the person changing the code will see it; a file's identity — its copyright, licence, and purpose — belongs in the ordinary // header block at the top of the file, and module-level prose belongs in a README or other Markdown file. These three homes do not overlap.
Example.
// a single-line comment
/* a comment that
spans several lines */
int32 total = 1_000_000 // trailing comment after code
IF total > 0 THEN {
/* inline, inside a block */
printline("positive")
}
Rationale. Two forms — one line-scoped, one delimited — cover every commenting need without a third variant, and allowing them anywhere whitespace is allowed means the reader is never forced to move a note to an unnatural place.
(c) Rule. A statement ends at a newline. Envzn has no semicolon and no other terminator character; the end of the line is the end of the statement, and a stray terminator is rejected rather than tolerated. Two statements may not share a line: writing a second statement after the first on the same line is a compile error, not a style warning. A newline is a statement terminator only at bracket depth zero: inside an unclosed ( or [ — a multi-line argument list, a wrapped compound condition, a long index or type-argument list — the newline is ordinary insignificant whitespace and the statement continues. The braces of a statement block never suppress termination; inside { … } every newline ends a statement as usual. A closing } (or the end of the file) may itself close the final statement of a block, so the one-line form IF (c) THEN { grade = "A" } is legal. Exactly ONE statement may precede that }: two separated only by a space fall under the two-statements-one-line error, one-line block or not. A continuation keyword must start its own line when the block it continues spans multiple lines; only after a one-line block may it share the line. The continuation keywords are ELSE, ELSE IF, RECOVER, FINALLY, and the DO-loop's WHILE/UNTIL tail. So IF (c) THEN { a() } ELSE { b() } is legal, and a multi-line block's cuddled } ELSE { is a layout error. (Pinned 2026-07-21/22 — the document was previously silent on the newline-inside-brackets, brace-closes-statement, and continuation-keyword-layout questions; raised by the GOLDEN clean-room probe, AMB-007/008/009. AMB-009 enforcement in the live parser is pending — ~744 existing cuddled sites migrate when it lands.)
Example.
int32 x = 5 // statement ends at the newline
int32 y = 10
int32 a = 1 int32 b = 2 // ERROR — two statements on one line
int32 c = 3; // ERROR — stray terminator character
Rationale. The newline is a boundary every reader already sees, so making it the terminator removes a redundant character and the whole class of "missing semicolon" errors; rejecting a stray terminator and one-line-two-statements keeps the visual layout an honest map of the statement structure.
(c.i) Rule. The single, narrow exception is the C-style counted-loop header described in I.H, where two semicolons separate the loop's three clauses and the whole header must occupy one line. The semicolon appears in exactly two places in the language: there, and as the constraint-group separator of the multi-parameter template-qualifier preamble (TEMPLATE: GIVEN TYPES V, H; V IS …; H IS …: — I.K, III.6). (Amended 2026-07-21 — the earlier "only place" claim predated the shipped Part-I template surface; found by the GOLDEN clean-room probe, AMB-025.)
Example.
FOR int32 i = 0; i < n; i = i + 1 { // the ONLY place a semicolon is legal
// ...
}
Rationale. The counted-loop header is one conceptual unit whose three clauses are conventionally read together on one line; confining the semicolon to exactly this construct preserves that familiar shape without reintroducing the terminator anywhere a statement actually ends.
(d) Rule. Every identifier a developer introduces begins with a letter, A through Z or a through z, and after the first character may contain letters, digits, and underscores. The rule is the same for a module, class, interface, group, struct, enum, method, field, or variable.
Example.
int32 count = 0
int32 wordCount2 = 0
CLASS HttpConnection { }
int32 2fast = 1 // ERROR — may not begin with a digit
Rationale. One naming rule for every kind of introduced name means a reader learns the shape of an identifier once and applies it everywhere, with no per-category special cases to remember.
(d.i) Rule. An identifier may not begin with an underscore. A leading underscore is legal C++ but is reserved in Envzn for compiler-generated names, and a developer who writes one receives a compile error.
Example.
int32 temp = 0
int32 _temp = 0 // ERROR — leading underscore is reserved
Rationale. Reserving the leading-underscore namespace for the compiler lets generated names coexist with developer names without ever colliding, and rejecting it at the source keeps that boundary visible rather than silent.
(d.ii) Rule. A reserved keyword may not be used as an identifier. The full keyword list is in the Appendix.
Example.
int32 total = 0
int32 CLASS = 0 // ERROR — CLASS is a reserved keyword
Rationale. A keyword must read as a keyword everywhere it appears; letting one double as an identifier would make the same token mean two things depending on position, which is exactly the ambiguity the language avoids.
(d.iii) Rule. The character $ is reserved exclusively for placeholder syntax inside format-string literals; using it anywhere else is a compile error.
Example.
String greeting := ("Hello, $1")->format(name) // $ is legal inside a format literal
int32 $count = 0 // ERROR — $ outside a format literal
Rationale. Giving $ one and only one job — format placeholders (I.F) — means the reader always knows what a $ signals and the tokenizer never has to guess whether it starts a placeholder or a name.
(d.iv) Rule. A locally declared type may not collide with a name the kernel reserves; the reserved set is given in I.N and the Appendix.
Rationale. The kernel's reserved names (I.N) form part of the language's built-in vocabulary; letting a local declaration shadow one would silently redefine a built-in type out from under later code, so the collision is rejected at declaration.
(e) Rule. Numeric literals may be written in three bases. A plain sequence of digits is decimal; a 0x prefix introduces hexadecimal; a 0b prefix introduces binary. Octal is not supported, because the leading-zero convention that usually carries it is a known source of silent error and the language declines to inherit it.
Example.
uint8 mask = 0xFF // hexadecimal
uint8 flags = 0b1011_0100 // binary
int32 million = 1_000_000 // decimal
uint32 color = 0xFF_00_FF
int32 legacy = 0755 // ERROR — octal is not supported
Rationale. Two explicit prefixes and a plain decimal cover every base a systems language needs; octal is refused specifically because the bare leading-zero form silently changes a number's value, and inheriting that would trade a memory-safe, intuitive surface for a legacy convenience.
(e.i) Rule. Underscores may be placed between digits as separators, purely for the reader's benefit — 1_000_000 and 1000000 are the same literal to the compiler — but an underscore may not lead or trail a literal.
Example.
int32 million = 1_000_000 // same literal as 1000000
uint32 color = 0xFF_00_FF // grouping inside a hex literal
int32 bad1 = _1000 // ERROR — underscore may not lead
int32 bad2 = 1000_ // ERROR — underscore may not trail
Rationale. Digit grouping is a pure readability aid the compiler ignores, so it never changes a value; forbidding a leading or trailing underscore keeps the separator unambiguously between digits and stops it from blurring into an identifier or a stray token.
(e.ii) Rule. A literal carries no fixed type of its own; it takes its type from the context that consumes it. The resolution rules, and the range check that rejects a literal too large for its target, belong to the type system and are given in I.D.
Example.
uint8 small = 200 // literal typed as uint8 by its target
int64 large = 200 // the same literal, typed as int64 here
uint8 over = 300 // ERROR — 300 exceeds uint8 range (see I.D)
Rationale. Because a literal has no intrinsic type, the same 200 fits wherever it is legal without a suffix or cast, and the target's type carries the range check. Relocating that work inward to the type system (I.D) keeps the literal surface clean.
(e.iii) Rule. A decimal literal may carry an exponent, in the ordinary scientific form: a mantissa, e or E, an optional sign, and one or more digits. The mantissa's decimal point is optional, so 1e5 is as valid as 1.5e5, and a literal carrying an exponent is a floating-point literal regardless of whether it has a point. The exponent form composes with the type resolution of (e.ii) like any other literal, and a decimal128 target decomposes it exactly from the lexeme rather than through a binary float.
Example.
float64 small = 1e-12 // no decimal point — still a float literal
float64 milli = 2.0e-3
float64 big = 1e5 // 100000.0
decimal128 d = 1e-30 // decomposed exactly, not via binary float
Rationale. Scientific notation is how the quantities a numeric program actually carries are written — tolerances, rates, magnitudes — so requiring a hand-expanded decimal would make the source less readable than the domain it models. Making the exponent alone enough to mark a literal as floating-point means 1e5 never has to be written 1.0e5 to be read correctly.
(f) Rule. The imaginary unit is the constant im (the complex value 0 + 1i). A coefficient written immediately before it forms a complex term: 4im is 4 * im, and 3.14im and 2.0e-3im likewise. A variable coefficient juxtaposes the same way — ip im for a number ip, equivalent to ip * im. i is not a numeric suffix and stays an ordinary identifier.
Example.
complex a = 4im // 4 * im
complex b = 3.14im
complex c = 2.0e-3im
number ip = 5.0
complex d = ip im // variable coefficient — ip * im
int32 i = 0 // 'i' is an ordinary identifier, not a suffix
Rationale. Choosing im over i/j keeps loop-variable names like i free for ordinary use. A Unicode imaginary unit ⅈ was rejected as both identifier-legal under UAX #31 and an i-confusable under UTS #39, so a plain two-letter constant is the safest readable choice.
(f.i) Rule. A complex in the two-part real + imaginary form composes by ordinary arithmetic and so is commutative — 3 + 4im and 4im + 3 both denote (3, 4), im being a genuine constant rather than a syntactic marker.
Example.
complex p = 3 + 4im // denotes (3, 4)
complex q = 4im + 3 // also denotes (3, 4) — commutative
Rationale. Because im is a real constant and not a positional marker, the two-part form is just ordinary addition, so it obeys the ordinary commutative law and the reader is never surprised by an order-dependent literal.
(f.ii) Rule. The canonical printed form parenthesizes the value, (3 + 4im), and a complex value used as a sub-operand of a tighter operator ((3 + 4im)^2) is written parenthesized so its boundary is unambiguous.
Example.
complex z = 3 + 4im
printline(("$1")->format(z)) // canonical printed form: (3 + 4im)
complex w = (3 + 4im)^2 // parenthesized as a sub-operand of ^
Rationale. A two-part complex spans an additive expression, so under a tighter operator like ^ its boundary would otherwise be ambiguous; the parentheses make where the value begins and ends explicit, and the canonical print mirrors that so read and write forms match. The literal form shipped 2026-06-23, superseding the earlier i-suffix design and its real-part-first rule; the (a + bim) rendering is specified here but not yet implemented — a complex value does not print in this form today.
(g) Rule. A character literal is a single Unicode code point between single quotes, such as 'a'. Its value is a code point across the full scalar range, not an octet. The escape sequences it accepts are catalogued with the char primitive in I.D.i: the familiar \n, \t, \r, \\, \', \0, the \v and \f controls, and the numeric \xHH, \uHHHH, and \UHHHHHHHH forms that name a code point directly.
Example.
char newline = '\n'
char tab = '\t'
char letter = 'a'
char nul = '\0'
char hexEsc = '\x41' // names a code point directly (I.D.i)
char uEsc = 'é'
char bigEsc = '\U0001F600'
Rationale. A character is a code point, not a byte, so the literal ranges over the full Unicode scalar space. The escape set is the familiar one plus direct numeric forms — conventional, and able to name any scalar unambiguously (full catalogue in I.D.i).
(g.i) Rule. A string literal is a sequence of characters between double quotes. String content is fixed at the literal: "Hello" is a constant the compiler interns.
Example.
String greeting = "Hello" // a constant the compiler interns
Rationale. Fixing string content at the literal lets the compiler intern each one as a shared constant — fast and memory-safe — while the reader sees exactly the text that ends up in the program.
(g.ii) Rule. Inside an ordinary string literal the dollar sign is always a literal dollar sign and never introduces interpolation — Envzn has no interpolating string literal, and positional composition is done instead through the ->format(...) method described in I.F.
Example.
String price = "Costs $5" // $ is a literal dollar sign here
String named := ("Hello, $1")->format(name) // composition via ->format (I.F)
Rationale. Keeping the literal inert — no hidden interpolation — means a $ in quotes always prints as written, and the reader never has to scan a literal for embedded expressions. Positional composition moves to the explicit ->format(...) call (I.F), where the substitutions are visible.
(g.iii) Rule. The escape sequences inside a string literal are the familiar small set: \n for newline, \t for tab, \\ for a backslash, and \" for a double quote.
Example.
String line = "col1\tcol2\n" // tab, then newline
String path = "C:\\Users" // an escaped backslash
String quote = "she said \"hi\"" // an escaped double quote
Rationale. A small, familiar escape set covers the characters a string literal cannot otherwise contain, with no exotic additions to learn — the same readability-first economy applied to comments and numeric bases.
(g.iv) Rule. A multi-line string opens and closes with /", and may span any number of source lines.
Example.
String block = /"
This string
spans several
source lines
"/
Rationale. A dedicated multi-line delimiter lets a literal carry embedded newlines directly, so the source reads as the text it produces instead of a chain of \n-joined fragments.
Section I.D — The Type System
How Envzn assigns a compile-time type to every value: the primitive types, literal typing, Unicode mode, the EMPTY absence model, the opaque type, the text and binary containers, String, the AS / INTO conversions, and the reserved type surface.
(a) Rule. Envzn is strongly and statically typed: every value has a type fixed at compile time, and the compiler will not produce a program in which a value is used at a type it does not have. Type inference is available only where the type is unambiguous; at method signatures and class fields the developer always writes the type explicitly. One ordering convention holds everywhere — the type precedes the name it qualifies.
Example.
int32 count // type first, then name
String name // never `name String`
METHOD area(float64 radius) RETURNS float64 { ... }
Rationale. Fixing the type at compile time is what lets the compiler reject a mis-typed use before the program runs. The type-first ordering is chosen for readability: a declaration reads left-to-right as "an int32 named count". Keeping the explicit type at the boundaries that matter — signatures and fields — means a reader never has to run inference in their head to know a shape.
Part I.D.i — Primitive types
The value types built into the language — the integer and floating-point widths, and the small set of truth/octet/character primitives.
(a) Rule. The primitive types are value types built into the language. They are not objects, carry no methods, and always hold a defined value — a primitive is never EMPTY. The signed and unsigned integers come in five widths each, the floating-point types in three.
| Signed | Unsigned | Floating point | Width |
|---|---|---|---|
int8 |
uint8 |
— | 8-bit |
int16 |
uint16 |
float16 |
16-bit |
int32 / int |
uint32 / uint |
float32 / float |
32-bit |
int64 |
uint64 |
float64 |
64-bit |
int128 / i128 |
uint128 / u128 |
— | 128-bit |
Example.
int32 count // defaults to 0
uint8 flags // defaults to 0
float64 ratio // defaults to 0.0
int i := (count INTO int) // `int` is the int32 alias
Rationale. Primitives are the language's floor of speed and predictability: no method-dispatch overhead, no allocation, and no absent state to guard against. That every primitive always holds a defined value is what removes an entire class of null-handling from the code that uses them.
(b) Rule. Every numeric primitive default-initializes to zero. float16 is a half-precision type intended for GPU work. The 128-bit integers int128 and uint128 (aliases i128 and u128) are full V1 primitives. They carry the arithmetic, comparison, and keyword-bitwise operators of I.F.ii like any other integer; their C++ lowering is given in ENVZN_IR_SPEC.md C.ii. The remaining primitives are boolean (TRUE/FALSE, defaults to FALSE, never abbreviated to bool); binary (an 8-bit opaque octet — a bit pattern rather than a number, defaulting to zero; its operator set is given in (c.i)); char (a single Unicode code point in U+0000..U+10FFFF, defaulting to the empty character; its operator set is given in (c.ii)); and opaque (a type-erased value primitive, described in I.D.v, which alone among primitives has no default and must be initialized explicitly).
Example.
boolean ready // defaults to FALSE — never `bool`
binary octet // an opaque 8-bit octet, defaults to 0x00
int128 huge = (n INTO int128) // i128 also valid
char c // defaults to the empty character
opaque slot // ERROR — opaque has no default, must be initialized
Rationale. boolean is spelled out rather than abbreviated so truth reads as truth. binary and uint8 are separate types rather than two spellings of one, because an octet and a small number are different things and the compiler is expected to say so: a bit pattern admits no arithmetic, and a number is not addressed a bit at a time. The language previously carried a third spelling, byte, documented as distinct from uint8 "by intent" — an intent nothing enforced, which is precisely why it was retired (I.D.i(c)). opaque is the deliberate exception to the zero-default rule because a type-erased slot has no meaningful zero to fall back to.
(c.i) Rule. binary carries exactly five things: assignment from another octet; the offset operators (+ - &+ &-) against an octet or an integer partner, whose result conforms to whatever slot receives it — int32 i = b + n computes as int32, binary b2 = b + n computes as an octet and is range-checked at runtime, raising the MathError PANIC of I.F.ii(a.iii) on overflow above 0xFF or underflow below 0x00, exactly as every other checked arithmetic does; the keyword-bitwise operators (BAND BOR BXOR BNOT RSHIFT LSHIFT); and the six comparisons, against an octet or any signed or unsigned integer. Nothing else — multiplication, division, remainder or exponentiation (* / ~/ % ^ &*) is E2135. No conversion crosses the octet boundary implicitly, in either direction: INTO/AS is the door both ways — outbound to every integer width and to float32/float64/char8/char16/char32 is INTO except the fallible AS int8; inbound from uint8/char8/int8 is INTO, from a wider integer AS. An octet read where a number is wanted (int32 n = b) is E2139; a number written where an octet is wanted (binary b = u) is E2145. Two shapes are not crossings and stay implicit: an integer masked by a literal bit pattern that fits an octet, landing in an integer slot (uint64 masked = word BAND 0xFF), and a literal beside a typed operand, which adopts that operand's type as any literal does (c == 0x0B beside a char). A binary literal is written in hexadecimal or binary — 0xC3, 0b11000011 — never in decimal (E2136); a bare 0 is exempt, since 0 and 0x0 name the same bit pattern. A literal outside 0x00..0xFF, in an octet slot or beside an octet operand, is E2147. The shift count is a uint8 quantity (E2137); a bare literal count adopts that type as any literal adopts its slot, and the count is policed exactly as I.F.ii(c.iv) polices every integer's: an octet is eight bits wide, so a count outside [0, 8) that the compiler can prove is E2130, and one it cannot prove raises the MathError PANIC at runtime — never a silent 0x00, and never the undefined behaviour C leaves. An octet is never a counter or an index — a FOR start, bound or step, a REPEAT count, or an array subscript typed binary is E2144; iterate with FOR e IN bytes or LOOP e IN bytes WITH INDEX i instead. And an octet has no truth value — IF/WHILE on a bare binary, NOT on one, or a boolean-returning RETURN of one, is E2149.
(c.ii) Rule. char carries exactly three things beyond assignment: the offset operators (+ -) against an integer partner, whose result is the code point's OWN type — a code point moved by an integer is still a code point — and is range-checked at runtime against that width's code-unit range (char8 0x00..0xFF, char16 0x0000..0xFFFF, char32 U+0000..U+10FFFF), raising the MathError PANIC of I.F.ii(a.iii) outside it, exactly as the octet's offset does; the distance c1 - c2 between two code points of the same width, which is an int32 count and may be negative; and the six comparisons, which order code points and are unchanged. Nothing else. char + char (two code points do not sum), n - c (subtracting a code point from a number names nothing), multiplication, division, remainder, exponentiation and the wrapping trio (* / ~/ % ^ &* &+ &-), and unary minus, are all E2150. A literal beside a char operand adopts the char type as any literal does, so '0' + d is an offset and c == 0x0B is a comparison. A plain integer landing in a char slot is a crossing like any other and takes INTO/AS: char digit = 0x30 + d is refused (E10096) where char digit = '0' + d says what was meant. (Amended 2026-09-10, gh #285 — the earlier reading, that a code point admits no arithmetic at all, was written into the rule and contradicted by every codec in the kernel, which offset and differenced code points thirteen times while the compiler said nothing.)
Example.
binary b = 0xC3 // hex — 195 would say how MUCH, not WHICH BITS
binary zero = 0 // 0 == 0x0 — the one decimal exempt from E2136
binary bad = 195 // E2136 — decimal in an octet slot
binary huge = 0x1FF // E2147 — outside 0x00..0xFF
binary lo = b BAND 0x0F // bitwise: yes
uint64 masked = word BAND 0xFF // not a crossing — a number masked by a literal stays a number
int32 i = b + n // offset conforms to the int32 slot
binary next = b + n // offset conforms to the octet slot — PANICs above 0xFF
binary bad2 = b * 0x02 // E2135 — an octet is not a magnitude
binary shift = b RSHIFT 4 // the literal count adopts uint8
int32 n2 = b // E2139 — an octet is not a number
binary b2 = u // E2145 — a number is not an octet
int8 n3 = (b AS int8) // the door — AS/INTO, never implicit
FOR i2 = 0 TO 10 STEP b { print(i2) } // E2144 — an octet is not a counter
IF b THEN { ... } // E2149 — an octet has no truth value
Rationale. The line is not "arithmetic is numeric so an octet has none" — it is narrower and more useful than that. Offsetting an octet moves within the 256 values it can hold, which is what every codec does: c - 0x30 turns the ASCII digit '7' into the value 7, and the result conforms to whatever slot it lands in because the meaning of the arithmetic doesn't change with the receiver — only whether it's checked as a bit pattern or as a number. Multiplying, dividing, taking a remainder or raising to a power all assume the value counts something, and a bit pattern counts nothing. Closing the implicit crossing in both directions is the same distinction carried through to assignment: int32 n = b used to read a bit pattern as a number for free, and binary b = u used to read a number as a bit pattern for free, and both hid the same reinterpretation that INTO/AS now has to say out loud. The two exempted shapes — a literal-masked integer, a literal beside a typed operand — are not octets at all; nothing in either was ever a stored bit pattern, so nothing is crossing. The literal rule is the same distinction at the surface: 0xC3 says which bits, 195 says how much, and only one of those is a thing an octet means. The shift count is a uint8 because you shift by a quantity, not by a bit pattern — the one place in an octet expression where a number is what is meant. And an octet is not a counter or a truth value for the same root reason it is not a magnitude: counting and testing both ask what a value is worth, and a bit pattern isn't worth anything.
(b.i) Rule. String is deliberately absent from the primitive list — it is a built-in value type with its own assignment and absence rules, not a primitive, and is the subject of I.D.vii.
Rationale. Listing String as a primitive would imply it shares the primitive contract: no methods, never EMPTY, bit-copied. But String carries methods, follows class-type assignment rules, and has its own always-present exception, so it belongs to its own category.
(c) Rule. A char is a Unicode code point and nothing narrower: it spans the full scalar range U+0000..U+10FFFF and is deliberately not a C char. The octet primitive is binary; char is the text primitive and uint8 the numeric one, and the language keeps the three apart on purpose — a char is never an octet, and neither is a number.
Example.
char letter = 'a' // valid code point — a valid char
char emoji = '\U0001F600' // one code point, not four bytes
binary b = 0xC3 // an octet — NOT a char, and NOT a number
Rationale. A C char is an 8-bit storage unit; an Envzn char is a decoded code point that does not fit in eight bits. Conflating the two is the classic Unicode bug — treating a byte as a character — so the language gives text (char), raw octets (binary), and small numbers (uint8) separate primitives that never implicitly interconvert.
(c.i) Rule. A character literal is written between single quotes. It accepts the escapes \n, \t, \r, \\, \', \0, \v (vertical tab), and \f (form feed), plus three numeric code-point escapes: \xHH (two hex digits), \uHHHH (four), and \UHHHHHHHH (eight).
Example.
char nl = '\n'
char quote = '\''
char tab = '\t'
char hexE = '\x41' // 'A'
char bmp = 'é' // 'é'
char astral = '\U0001F600' // 😀
char space = ' ' // ordinary char literal
Rationale. The three numeric escapes name a code point directly across the whole scalar range. Any character, including ones a keyboard cannot type, is expressible as an ordinary char literal without leaving the text primitive.
(d) Rule. char is the canonical name in a family of three widths: char8 is a single 8-bit UTF-8 code unit (char8 ≡ uint8 at storage) and char16 a single 16-bit UTF-16 code unit (char16 ≡ uint16). char32 (≡ uint32) is the code-point width, of which char is the alias. The three name three distinct types; a value of one width is never implicitly a value of another. Crossing widths is a conversion, always explicit through the AS/INTO operators of I.D.viii (hosted on CharWidthConverter).
Example.
char8 unit = 0xC3 // lead byte of a 2-byte UTF-8 sequence — NOT U+00C3
char32 cp = (unit INTO char32) // explicit widen — required across widths
char32 same = someChar16 // ERROR (W10095) — implicit cross-char conversion
Rationale. A code unit is not a code point: a char8 of 0xC3 is the lead byte of a two-byte sequence, not the scalar U+00C3. Because encoded-unit work and decoded-code-point work are genuinely different, the widths are kept as distinct types so the compiler forces an explicit conversion at every boundary rather than silently reinterpreting bytes as scalars.
(d.i) Rule. An implicit cross-char-type conversion — one width written where another is expected, with no AS/INTO — is diagnosed. The analyzer warns W10095 on every char-type pair except char16 → char32, which is exempt because a non-surrogate UTF-16 unit is the same UTF-32 scalar. The warning is suppressed inside an OPERATOR AS/INTO body, where the conversion is being defined.
Example.
char16 u = 0x00E9
char32 c = u // no warning — char16 → char32 is scalar-equivalent
char8 b = 0x41
char32 d = b // WARNS (W10095) — implicit char8 → char32
Rationale. The one exempt pair is the one case where the reinterpretation is provably lossless, so demanding a conversion call there would be noise. Suppressing the warning inside an OPERATOR AS/INTO body is necessary because that body is precisely where the conversion is legitimately being authored.
(d.ii) Rule. char8 INTO char32 widens by bit-equivalence and never fails; char32 AS char8 narrows and is pipe-XOR, succeeding into one char8 only on single-byte ASCII (a code point may need one to four UTF-8 bytes). The bulk encode/decode between widths lives in the kernel's UTFCodec.
Example.
char32 wide = (asciiUnit INTO char32) // total — never fails
IF (wide AS char8) THEN { char8 narrow = $RETURNED } // pipe-XOR — succeeds only on ASCII
ELSE { printline($!) } // non-ASCII code point cannot collapse to one char8
Rationale. Widening is total because every 8-bit unit is a valid 32-bit scalar. Narrowing is fallible because most code points do not fit in a single UTF-8 byte, so the collapse can genuinely fail and must be checked rather than truncated. Bulk multi-byte encode/decode is the job of UTFCodec, not of the single-value width operators.
(e) Rule. A char8 and a char32 value may appear in the same BAND / BOR / LSHIFT / RSHIFT expression, where each operand contributes its storage bits to an integer-typed result. Mixing widths in a bitwise expression is not a char8 → char32 conversion — it is integer arithmetic over bits.
Example.
char32 cp = (cp LSHIFT 6) BOR (contUnit BAND 0x3F) // UTF-8 decode step — no per-step conversion
char32 wide = (c INTO char32) // treating one char8 AS one char32 is still explicit
Rationale. Making bit operations mix widths freely is what lets a UTF-8 decoder be written without a conversion call at every shift-and-or step. It is not a hole in the width discipline: the bitwise expression yields an integer over bits, whereas reinterpreting one isolated char8 as one char32 value remains the explicit (c INTO char32) conversion.
(f) Rule. Envzn provides no companion or wrapper classes for primitives — no Integer, no Float64, no boxed Boolean. When a primitive must become text or change width, that work is done by the AS/INTO conversion operators of I.D.viii, not by methods on a wrapper.
Example.
String label := (42 INTO String) // conversion operator, not `42->toString()`
float64 d = (myInt32 INTO float64) // widen via operator, no wrapper class
// Integer big := ... // ERROR — no boxed primitive types exist
Rationale. Wrapper classes exist in other languages to give primitives a method surface and a nullable form; Envzn needs neither — conversions are operators and absence never applies to a primitive — so the boxed forms would be pure overhead and are simply absent.
(g) Rule. Two further numeric primitives are designed for V1: number and complex. Each is a special value-type like opaque — a primitive backed by a compiler-known special class (Number / Complex) that a developer never instantiates directly, value-copied like any primitive. A number holds an integer (int64) or a float (float64) — exactly one at a time, self-aware of which. It is the opt-in choice when a value is genuinely int-or-float, since a number parameter accepts either. It is never the default; a developer who means int32 writes int32. Widening into a number is implicit; narrowing out requires an explicit AS/INTO and is runtime-fallible (the held subtype is a runtime fact).
Example.
number n = 5 // holds int64 — widened in implicitly
number m = 2.0 // holds float64
IF (n AS int32) THEN { int32 i = $RETURNED } // narrow out — explicit + fallible
ELSE { printline($!) }
Rationale. number exists for the genuine int-or-float case that a fixed width cannot express. Making it the default would silently cost the predictability of a concrete width, so widening in is free while narrowing out is checked — matching where the loss of information actually is.
(g.i) Rule. A complex is a pair of numbers, written real + coeff im (commutative, I.C), so a single type spans both Gaussian-integer (3 + 3im) and float (3.0 + 4.0im) complexes. It exposes .real, .imaginary (each a number), .conjugate (a complex), and .magnitude / .phase (each a float-subtype number). The four arithmetic operators work on each, with a real widening implicitly to a complex (value, 0).
Example.
complex gauss = (3 + 3im) // Gaussian integer
complex z = (3.0 + 4.0im) // float complex
number re = z.real // 3.0
number mag = z.magnitude // 5.0 — float-subtype number
complex sum = z + 1 // real 1 widens to (1, 0)
Rationale. One complex type spanning both integer and float components (rather than separate fixed primitives) follows from complex being a pair of numbers, each self-aware of its subtype. Widening a real to (value, 0) lets ordinary arithmetic mix reals and complexes without a conversion call.
(g.ii) Rule. Equality on complex is defined but the ordering operators are not — complex numbers have no canonical order, so writing < between two complexes is a compile error. A complex extracts back to a real only through complex INTO <real> (checked, succeeding iff the imaginary part is zero); complex AS <real> is a compile error, the real-part-vs-magnitude choice being ambiguous — use the accessors.
Example.
IF (z INTO float64) THEN { float64 r = $RETURNED } // succeeds iff z.imaginary == 0
// IF z1 < z2 THEN { ... } // ERROR — complex has no canonical order
// float64 r = (z AS float64) // ERROR — real-part vs magnitude ambiguous
float64 justReal = z.real // use an accessor when you mean a specific part
Rationale. Ordering is undefined because there is no order on the complex plane consistent with arithmetic; AS-to-real is rejected because "the real part" and "the magnitude" are both plausible lossy readings and the language refuses to guess — the accessors make the intended one explicit.
(g.iii) Rule. A number reports its held subtype through n->kind(), which returns a NumberKind { INTEGER, FLOAT, UNSIGNED } enum. That enum is the typed, MATCH-able V1 introspection surface — the stand-in for the V2 runtime WHEN n IS int64 narrowing.
Example.
MATCH n->kind() {
WHEN NumberKind.INTEGER: { print("int") }
WHEN NumberKind.FLOAT: { print("float") }
WHEN NumberKind.UNSIGNED: { print("large positive int") }
}
Rationale. Until V1 gains runtime type narrowing, kind() gives a MATCH-able, statically-typed way to branch on which arm a number currently holds. It is the same information a future WHEN n IS int64 would carry, exposed as an enum today.
(g.iv) Rule. The third NumberKind arm, UNSIGNED, is a purely value-classified overflow extension. It is never written at the surface, and is reached only when an integer value overflows the int64 arm into (int64_max, uint64_max] — giving a number full [int64_min, uint64_max] integer fidelity. Because of it, integer arithmetic on a number promotes rather than wraps — a sum that overflows int64 classifies up the ladder INTEGER → UNSIGNED → FLOAT. The arms hold disjoint value ranges, so an INTEGER never compares equal to an UNSIGNED.
Example.
number big = int64Max // INTEGER arm
number sum = big + big // promotes to UNSIGNED (or FLOAT) — does NOT wrap
// bare int* scalars PANIC on overflow (I.F.ii(a.iii)) — this promote-not-wrap policy is number-only
Rationale. Promoting up the ladder preserves the mathematical value that a MathError PANIC would otherwise stop the computation over, giving number full integer fidelity across the whole signed-and-unsigned 64-bit range. This is one of the two documented exceptions to the overflow-PANIC rule of I.F.ii(a.iii) (the other is decimal128, which rounds); disjoint arm ranges keep equality honest across the ladder.
(g.v) Rule. A number is Equatable and ordered; a complex is Equatable only. == compares by mathematical value across subtypes on both (so number(3) == number(3.0) is TRUE, and complex equality is component-wise). A number additionally carries the four ordering operators < > <= >=, which order by mathematical value across the INTEGER/UNSIGNED/FLOAT arms — the same value-based rule == follows. A complex carries no ordering: ℂ has no canonical order, and writing one is a compile error. Neither type is Hashable, so neither is a valid Dictionary/Set key, though both remain fine as Dictionary values and ordinary collection elements.
Example.
IF number(3) == number(3.0) THEN { ... } // TRUE — == is value equality (Equatable)
IF number(2) < number(2.5) THEN { ... } // TRUE — ordering is by mathematical value
number n = 5
IF n > 4 THEN { ... } // OK — the 4 widens into a number (g)
Dictionary[String, number, DefaultHasher[String]] scores // OK — number as a VALUE
// Dictionary[number, String, DefaultHasher[number]] bad // ERROR — number is not Hashable, not a key
// IF (3 + 4im) < (1 + 2im) THEN { } // ERROR — complex has no ordering
Rationale. The two exclusions this rule once bundled have different force, and were split on 2026-09-02. Hashing genuinely cannot work: which arm a number holds is a runtime fact, so there is no statically-stable key — that keeps both types out of Dictionary/Set keys. Ordering was excluded on the grounds that NaN compares false to everything, but the language does not apply that argument to float64, which carries exactly the same NaN and is ordered anyway (interfaces.ev: built-in Comparable behaviour for float32/float64). Holding number to a stricter standard than the type it can hold made number unusable for the ordinary comparisons it exists to serve. A number is therefore ordered, and NaN behaves as it does on float64 — every comparison against it is FALSE. complex keeps its exclusion for the reason that was always specific to it: ℂ has no canonical order at all. Neither change affects use as a stored value, where no such property is needed.
(g.vi) Rule. Being value-class-backed, a number/complex legitimately carries the methods and operators its backing class defines — the sanctioned exception to "primitives have no methods." The companion approximate-equality operators ≈/≉ are described in I.F.ii.c. A number renders tag-aware: an integer subtype prints as a decimal integer (5), a float subtype keeps the float distinction visible (2.0). A format-spec radix letter (x/X/o/b) is honoured only on an integer-subtype value, and silently ignored on a float.
Example.
number i = 5 // renders "5"
number f = 2.0 // renders "2.0" — float distinction visible
String hex := ("$1:x")->format(i) // radix honoured on integer subtype
String noHex := ("$1:x")->format(f) // radix silently ignored on a float subtype
IF a ≈ b THEN { ... } // approximate equality — I.F.ii.c
Rationale. number/complex are the sanctioned method-carrying primitives because they are backed by real value classes; the method surface is where their arithmetic and formatting live. Tag-aware rendering preserves the one bit of information a bare width would lose — whether the held value is an integer or a float — so 2.0 never silently prints as 2.
(g.vii) Rule. number is shipped in V1. It is a value-class over the kernel UNION storage construct, carrying arithmetic, ==, ≈, kind(), the AS/INTO conversions, and tag-aware formatting. The UNSIGNED overflow arm adds a third NumStore arm, promote-not-wrap integer arithmetic computed in int128, a total AS uint64, and disjoint-range comparison. complex is shipped in V1 as a value-class pair of numbers. It provides the im constant with commutative literals, the four arithmetic operators and unary −, component-wise ==, and the .real/.imaginary/.conjugate/.magnitude/.phase accessors. It converts through complex INTO String (canonical (a + bim)) and the fallible complex INTO float64. Math.squareRoot is redefined to RETURNS complex, so complex c = Math.squareRoot(-4.0) is (0 + 2im).
Example.
complex c = Math.squareRoot(-4.0) // (0 + 2im) — squareRoot RETURNS complex
String s := (c INTO String) // canonical "(0 + 2im)"
Rationale. Recording the ship dates, backing files, and the superseded fixed-primitive design keeps the spec aligned with what actually exists in the kernel. Redefining Math.squareRoot to return complex is what lets squareRoot(-4.0) yield a real answer rather than fail on a negative input.
(h) Rule. The UNION construct is a kernel-internal raw-storage primitive: a named block of typed fields that, unlike a STRUCT, holds exactly one at a time and carries no discriminant of its own. It lowers directly to a C++ union. It exists to give a value-class a tight tagged-union layout without trading the memory-safety floor. A UNION is legal only inside the ENVZN kernel — in ordinary code it is a compile error, E6062 — declares no INIT and no methods (typed value arms only), and every access goes through the enclosing value-class's tag. UNION is a reserved keyword. Status: shipped in V1 (2026-06; backs number).
Example.
// inside the ENVZN kernel only:
// the Number value-class is { NumberKind tag; UNION { int64 i; float64 f } }
UNION NumStore { int64 i float64 f } // one arm live at a time; no discriminant of its own
// UNION Foo { ... } // ERROR (E6062) — UNION outside the kernel
Rationale. A UNION gives Number an eight-byte-plus-tag layout instead of a fat all-fields class, but the one-arm-live read is unsafe. So the language encapsulates it entirely: kernel-only, no methods, every read routed through the enclosing tag. It is the "relocate the cost inward" tiebreak of I.A made concrete — the unsafe machinery lives behind a safe value-class surface and is never exposed to a general developer.
Part I.D.ii — Numeric literals and their types
How a numeric literal takes its type from the context that expects it, and when a range check fires.
(a) Rule. A numeric literal carries a polymorphic marker rather than a fixed type until the type-checker resolves it against its context's expected type. A context-free integer literal defaults to int64 — the language's default integer — and a context-free decimal literal to float64, but any context supplying an expected type gives the literal that type instead, provided it fits. The fit is checked after any constant folding of literal arithmetic, so an out-of-range folded result is a compile error rather than a runtime overflow. A hexadecimal or binary literal that does not fit its declared target is rejected the same way. (Corrected 2026-09-07: this clause read int32 until now. The default was changed to int64 several sessions ago and the code has said so since — analyze/expr_typing._literal_default_type returns the int64 default for a decimal spelling — so the divergence was in this document, not in the compiler. A decimal spelling says nothing about width, so it takes the language's default integer type rather than a width inferred from magnitude; typing 1 by magnitude would make count - 1 a sign-mix in every loop in the repo.) A floating-point literal must land on a finite value of its type. A folded value that is non-finite — an overflow such as 1e400, or a literal division like 1.0 / 0.0 — is a compile error, because NaN and Infinity are computation results and are never developer-authorable. A non-zero literal that underflows to exactly zero (1e-400) is rejected as a lost value, while a representable subnormal (1e-320) is accepted. (decimal128 folds its literal exactly from the lexeme, so its fit is checked as coefficient-and-exponent, not through binary float.)
Example.
100 // context-free integer literal → int32
3.14 // context-free decimal literal → float64
int8 small = 100 // literal takes int8 — it fits
int8 bad = 100 + 28 // ERROR — folds to 128, out of int8 range
uint8 mask = 0xFF // fits — 255
uint8 tooBig = 0x1FF // ERROR — hex literal exceeds uint8
Rationale. Letting a literal adopt its context's type is what makes int8 small := 100 work without an explicit conversion. Checking the fit after constant folding means 100 + 28 is caught at compile time exactly as the bare literal 128 would be, so the overflow can never survive to runtime.
Part I.D.iii — Unicode mode
The V3 per-module Unicode modes, what ->length() counts under each, and the link-time mode-match rule — designed, not implemented.
(a) Rule. The per-module Unicode-mode system described in this Part is V3, designed and not implemented. Today every module compiles under one behaviour: a String stores UTF-32 code points and ->length() is the code-point count. The rules below fix the shape that system will take. Text in Envzn is Unicode, and every module declares in its manifest which of three Unicode modes it compiles under. The declaration is optional; a module that says nothing compiles as UTF16. The mode is recorded in the compiled binary, and the compiler refuses to link two modules built under different modes. A mismatch is a build-time error, not a latent inconsistency.
| Mode | Internal storage | ->length() counts |
Suited to |
|---|---|---|---|
UTF8 |
UTF-8 bytes | bytes | servers, networking, file I/O |
UTF16 |
UTF-16 code units | UTF-16 code units | general and multilingual application work |
GRAPHEME |
UTF-32 with a cluster map | visible characters | V3 — reserved. A compile error if declared before then. |
Example.
// module manifest (<name>.json):
// "unicodeMode": "UTF8" // servers, networking, file I/O
// (omit the field entirely → compiles as UTF16)
// "unicodeMode": "GRAPHEME" // ERROR before V3 — reserved
Rationale. Two modules disagreeing on what ->length() means would be a silent, corrupting inconsistency. Baking the mode into the binary and refusing to link across a mismatch turns it into a loud build-time failure.
(b) Rule. The mode affects how text is stored and what ->length() counts. It does not affect what a char is: a char is always a Unicode code point regardless of mode. Because ->length()'s meaning will shift with the mode, a string also exposes ->size(), the UTF-8 byte count, so code needing a specific count independent of the module's mode can ask for it by name. A grapheme count arrives with the modes themselves in V3.
Example.
int32 modeCount = s.length // meaning depends on the module's mode
int32 bytes = s.byteLength // always the UTF-8 byte count
int32 points = s.codePointLength // always the code-point count
// int32 g = s.graphemeLength // reserved for V3
char first = ... // a char is a code point under every mode
Rationale. Making ->length() mode-dependent serves the common case: each mode's most natural count. A by-name count method gives any code a stable, mode-independent number when it needs one, so you never have to know the module's mode to get the byte count.
Part I.D.iv — Absence and EMPTY
The single absence model: no optional type, no null — a class-typed binding either holds an instance or is EMPTY, and presence must be proven before use.
(a) Rule. Envzn has no optional type and no ? type-suffix. Every field, variable, parameter, and return is declared with its bare type. Writing a ? anywhere a type may appear — as a declaration suffix, in a RETURNS clause, inside a type argument — is a compile error, rejected by the parser.
Example.
String name // bare type — correct
METHOD find() RETURNS Circle { ... } // bare return type
// String? maybe // ERROR — no `?` type-suffix
// METHOD find() RETURNS Circle? { ... } // ERROR — parser rejects `?`
Rationale. This is the single most important type-system rule to absorb, because it differs sharply from the languages Envzn most resembles. There is one absence model, not two: absence is a state, not a type constructor, so there is no ? to write.
(b) Rule. Absence is a state a class-typed binding may occupy, not a wrapper on a type. A binding of a class type either holds an instance or is EMPTY, and EMPTY is the only absence value — there is no null. A binding of a primitive type can never be EMPTY: a primitive always holds a defined value, so absence does not arise for it.
Example.
CLASS Person {
String firstName // holds a String, never EMPTY
String middleName // holds a String, never EMPTY
String lastName
Circle badge // owns a Circle, or is EMPTY
}
Rationale. The distinction is exactly the two columns of the type system — class-typed values can be absent, primitive values cannot — and the language attaches no syntax to it because the type already carries the information: knowing a binding's type is knowing whether it can be EMPTY.
(c) Rule. EMPTY is a reserved literal, valid only as the value of a class-typed binding. It is a compile error everywhere else: assigned to a primitive, a STATUS, an enum, or a struct, it names a state those types do not have. A class-typed field left unassigned by every path through INIT is EMPTY; a class-typed field is never silently uninitialized.
Example.
Circle badge := EMPTY // valid — class-typed binding
IF badge IS VALID THEN { ... } ELSE { ... } // valid — presence test
// int32 n = EMPTY // ERROR — a primitive has no absent state
// STATUS s = EMPTY // ERROR — STATUS has no EMPTY state
Rationale. Restricting EMPTY to the one abstraction that has an absent state keeps the model coherent. Absence means exactly one thing — a class-typed binding holding no instance — and the compiler catches any attempt to apply it to a primitive, STATUS, enum, or struct, where "absent" is meaningless.
(d) Rule. Using a class-typed value requires that its presence be proven. The null-safety analyzer will not finish a compilation in which a class-typed value is dereferenced where it cannot prove the value is not EMPTY. Presence is established in one of two ways: the IS VALID / IS NOT VALID test, or the ?? coalescing operator.
IS VALID(and its negationIS NOT VALID) branches on presence. Inside the truthy branch ofIS VALID, or after an early return guarded byIS NOT VALID, the analyzer narrows the value to non-EMPTYand permits direct field and method access.- The coalescing operator
??substitutes a default:a ?? bevaluatesaexactly once, yields it if present, otherwise evaluates and yieldsb; the result is always present.
Example.
IF person.middleName IS VALID THEN {
print(person.middleName) // present — narrowed, direct access
}
ELSE {
print("no middle name") // absent
}
IF candidate IS NOT VALID {
RETURN (EMPTY)
}
candidate->use() // analyzer carries the narrowing past the early return
String display := middleName ?? "N/A" // default substitution
Rationale. Each of the three fits a different need: MATCH when both states need handling, IS VALID when a branch or guarded early return suffices, ?? when the code wants a value rather than a branch. The analyzer refusing to finish otherwise is what makes "dereference of an absent value" impossible to ship. The exhaustive-MATCH requirement exists because a missing arm on a statically two-state value can only be an oversight.
(e) Rule. The presence vocabulary built on IS VALID and the failure vocabulary built on STATUS are kept strictly apart. A possibly-EMPTY class value is tested with IS VALID / IS NOT VALID; a STATUS value is tested with IS SUCCESS, IS FAILURE, and IS PARTIAL_SUCCESS. The two never alias, and the compiler rejects a test applied to the wrong abstraction. The full table of IS VALID subjects is in I.I.
Example.
IF badge IS VALID THEN { badge->draw() } // presence test — class value
IF s IS FAILURE THEN { printline($!) } // failure test — STATUS value
// IF badge IS SUCCESS THEN { ... } // ERROR — presence tested with a failure verb
// IF s IS VALID THEN { ... } // ERROR — STATUS tested with a presence verb
Rationale. Absence ("is there a value?") and failure ("did an operation succeed?") are different questions, and letting their verbs alias would blur the two into a single ambiguous truthiness. Keeping the vocabularies disjoint — and rejecting a cross-applied test — makes every check say exactly which question it is asking.
(f) Rule. Absence and ownership are independent properties. Whether a class-typed binding may be EMPTY says nothing about whether it owns what it holds; ownership is decided by the REFERENCE keyword and nothing else. A plain Type field is owning and may be EMPTY or hold its target. A REFERENCE Type field is non-owning and is never EMPTY: the borrow checker proves it live, so it must be bound with =@ in INIT (E1121), and that always-valid guarantee is exactly what lets it lower to a raw pointer with no runtime check. A back-pointer must therefore be non-owning — a REFERENCE cannot keep memory alive — to avoid an ownership cycle, and where such a back-pointer may legitimately have nothing to point at, as the root of a tree or the head of a list does, the rung that is non-owning and nullable is WEAK REFERENCE, whose deref yields (REFERENCE T | EMPTY) and narrows through IS VALID. WEAK REFERENCE is specified in docs/specifications-drafted/weak-reference-design.md and is reserved, pending implementation: until it lands there is no way to spell a nullable non-owning edge at all, which is why kernel/src/LinkedList.ev carries no per-node prev. The full ownership model is I.I. (Corrected 2026-09-11: this clause read that a REFERENCE field “may be EMPTY” — a statement E1121 has always rejected, and one that made the nullable non-owning edge look spellable when it is not. The example below was itself unbuildable for a root Node on exactly that point.)
Example.
CLASS Node {
Node next // owning — may be EMPTY or hold the next Node
REFERENCE Node parent // non-owning back-pointer — bound in INIT, never EMPTY
}
// A ROOT node, whose parent is genuinely absent, needs `WEAK REFERENCE Node parent`
// and cannot be spelled in V1 — see the rule above.
Rationale. Conflating absence with ownership is a common first mistake, so the spec separates them explicitly. Because a plain field owns what it holds, an unguarded back-pointer would form an ownership cycle. The language's answer is not to break the cycle silently but to make the developer mark the back-pointer REFERENCE, encoding the non-owning intent in the type. Holding REFERENCE to always-valid is what keeps that mark free at runtime, and the price of it is that nullability has to be asked for separately rather than assumed — which is the whole reason WEAK REFERENCE exists as a third rung rather than as a property a REFERENCE could simply have.
Part I.D.v — The opaque type
The type-erased primitive for heterogeneous storage: how it wraps, unwraps destructively, and clones through a captured cloner.
(a) Rule. opaque is a primitive value type carrying a typed value behind a runtime type tag. It exists for the heterogeneous-storage cases the static type system cannot otherwise express; a dictionary whose values differ in type per key is the canonical example. It is the mechanism by which a single collection slot can hold a value whose type is unknown until recovered.
Example.
opaque box := opaque[Circle]->wrap(c) // one slot holding a value of statically-unknown type
Rationale. A homogeneous container cannot hold values of differing types (bounded genericity, I.A). The language therefore provides one type-erased primitive for the genuine heterogeneous case, rather than weakening the static type system everywhere.
(b) Rule. opaque is not a parametric type. The bracket form opaque[T] is call-site syntax for the two static operations only. A field or local declared as opaque[Esquire] is a compile error: the type position is always the bare word opaque. The type exposes four operations — two type-parameterized statics (wrap, unwrap) and two plain instance methods (loaded, clone):
opaque[T]->wrap(value)produces anopaquecarryingvalue, recordingTin the runtime tag and capturing, at this moment, the cloner a laterclone()will use.opaque[T]->unwrap(d)recovers the value. It is a pipe-XOR producer: it yields the moved-out value whenTmatchesd's runtime tag, and aSTATUSfailure on a tag mismatch or an already-emptiedopaque. It never panics. Recovery is destructive on success: a successfulunwrapconsumes the value, and a laterunwrapof the sameopaquefinds it empty. On failure it is a no-op, so a caller may retry a failedunwrapwith a differentT.loaded()reports whether theopaquecurrently carries a value:TRUEafter a successfulwraporclone,FALSEafter anunwraphas consumed the payload.clone()deep-copies the wrapped value through the cloner captured at wrap time; it is non-destructive, leaving both source and copy loaded, and cloning an unloadedopaqueyields a fresh unloaded one.
Example.
opaque box := opaque[Circle]->wrap(c) // bracket form — call-site only
// opaque[Circle] field // ERROR — bracket form is not a declarable type
IF opaque[Circle]->unwrap(box) THEN { Circle back := $RETURNED } // destructive on success
ELSE { ... } // tag mismatch or already emptied — box unchanged, retry OK
boolean has := box->loaded() // FALSE after a consuming unwrap
opaque copy := box->clone() // non-destructive deep copy via captured cloner
Rationale. Keeping opaque non-parametric — bracket form for the statics only — is what prevents type erasure from leaking into declarations and re-introducing the metalanguage bounded genericity refuses. Destructive-on-success/no-op-on-failure unwrap gives clean move semantics with a safe retry path: you can probe a tag with one T, and on a miss try another without having disturbed the payload.
(c) Rule. When T is a class type, the opaque temporarily owns the instance; unwrap moves that ownership to the caller and leaves the opaque empty. The wrapped type must itself be a concrete, non-absent type. Equality and ordering on opaque values are not defined in V1 — a caller compares by unwrapping to a typed value first.
Example.
Dictionary[String, opaque, DefaultHasher[String]] storage
storage["esquire"] := opaque[Esquire]->wrap(e)
storage["manuscript"] := opaque[Manuscript]->wrap(m)
IF storage["esquire"] THEN {
opaque slot =@ $RETURNED
IF opaque[Esquire]->unwrap(slot) THEN {
Esquire back := $RETURNED
// use back
}
}
Rationale. The canonical heterogeneous-storage idiom pairs a Dictionary of opaque values with a typed unwrap at each use site, recovering the static type exactly where it is needed. Equality/ordering are left undefined because comparing two type-erased boxes is meaningless without knowing their contents' types — so a caller unwraps to a concrete type and compares that.
Part I.D.vi — Text and binary containers
The two-by-two grid of text/binary × immutable/mutable containers, the column discipline that keeps them apart, and the non-owning views that host the search surface.
(a) Rule. Envzn provides four built-in containers for sequences of characters or bytes, understood as a two-by-two grid along two independent axes: what the sequence holds — text (Unicode characters) or binary (uninterpreted bytes) — and whether it may change — immutable (fixed at construction) or mutable (built up in place).
| Text — Unicode, code-point indexed | Binary — opaque bytes | |
|---|---|---|
| Immutable | String |
ByteBuffer |
| Mutable | DynamicString |
DynamicByteBuffer |
Example.
String greeting = "hi" // immutable text
ByteBuffer payload := readBytes() // immutable binary
DynamicString builder // mutable text
DynamicByteBuffer sink // mutable binary
Rationale. Two orthogonal axes give exactly four containers with no overlap and no gap. Every character/byte sequence need lands in one cell, and the grid makes the choice — text vs. binary, immutable vs. mutable — explicit at the declaration.
(b) Rule. All four forms are heap-allocated. The mutable pair were stack-resident with a small-buffer optimization until 2026-08-22, and were VALUE CLASS declarations until 2026-08-24; with the inline storage gone they are ordinary classes, and the only axis that still separates the pair from the other is mutability. The mental model: a String is a constant character buffer with a length, a ByteBuffer the same for raw bytes, and the two Dynamic forms are the growable character and byte builders you append into and then snapshot.
Rationale. Starting the mutable builders on the stack was meant to make the common "build a small string, then snapshot it" path allocation-free until it genuinely needed the heap. It did not: the inline capacity and the growth threshold were owned by different halves of the implementation, so the inline buffer never once engaged, and removing it moved the benchmarks by well under a percent. What survives is the readable surface, which never depended on the storage — a builder you append into and then snapshot, whatever backs it.
(c) Rule. The two axes are enforced, not merely suggested. The text pair is built from char (a full Unicode code point), the binary pair from binary (an uninterpreted octet). The two are deliberately not interchangeable: text containers expose only code-point operations, and byte-level access lives exclusively on the binary containers. Text containers are not binary-safe — they assume valid UTF-8 content, and carrying arbitrary bytes in a String is a defect. Text prints as text; binary prints as hexadecimal. The mutable forms are the builders for the immutable forms — mutate a DynamicString in place, then snapshot to a String with toString() (likewise DynamicByteBuffer → ByteBuffer), and a snapshot always stays within its column.
Example.
DynamicString db
db->append("café")
String snapshot := db->toString() // snapshot stays in the text column
// crossing columns is a conversion, never a container method:
ByteBuffer bytes := (snapshot INTO ByteBuffer) // hosted on TextConverter (I.D.viii)
Rationale. Reaching for an individual byte is precisely the act of stepping from the text column into the binary column, which is the entire reason the binary pair exists. Keeping the two apart stops arbitrary bytes from silently corrupting a UTF-8 String. Crossing between text and binary (or between either and numbers/encodings) is a conversion, and conversion is the job of the AS/INTO operators of I.D.viii (the String↔ByteBuffer bridge on TextConverter), never of a method on the container.
(d) Rule. Alongside the four owning containers the Text family carries two non-owning views: StringView, a bounded window of code points into a String over char32[], and ByteBufferView, a window of bytes into a ByteBuffer over binary[]. Both are critical public-facing members, not implementation details. A view is obtained with ->view(start, length) and is non-owning: it borrows the container's storage rather than copying. It is a short-lived local descriptor that must not outlive the container, be stored into a field, or be returned as an escaping value — the last two are rejected at compile time (E3044), on a view obtained through ->view(start, length). A view over a parameter may be returned: that storage is the caller's and outlives the call by construction. A view narrows further with its own ->view(start, length) (a sub-window of a window is a window) and materialises its slice into a fresh owning array with ->copy() — char32[] from a StringView, binary[] from a ByteBufferView.
Example.
StringView win := text->view(0, 5) // non-owning window of 5 code points
StringView sub := win->view(1, 3) // sub-window — bounds checked against `win`
char32[] pts := sub->copy() // materialise into an owning array
String back := CREATE String(sub->copy()) // escape hatch back to owned storage
Rationale. Making views non-owning is what makes slicing free — no copy until you ask for one with ->copy(). The restrictions (no outliving the container, no storing, no escaping) are what keep a borrowed window memory-safe. ->copy() is the deliberate escape hatch back to owned storage, wrappable with CREATE String(...) / CREATE ByteBuffer(...).
(d.i) Rule. One error policy across all six. An out-of-range operator [] panics with IndexOutOfBoundsError — on every one of the six, text and binary alike. A search that may legitimately find nothing (find, lastIndexOf, find_first_of, find_first_not_of) reports a miss through a pipe-XOR (int64 index | STATUS s), never a -1 sentinel. Everything else is total: substring and subBuffer panic on a bad range rather than returning a status, and both accept length == 0 as an empty result rather than an error. There is exactly one element accessor, []; a second fallible at() existed on the binary pair until 2026-08-27 and is retired.
Rationale. The binary pair once declared the opposite policy — "no method throws" — and that single divergence is what generated the drift the two axes accumulated: a second element accessor, a pipe-XOR subBuffer facing a throwing substring, and a -1 the language rejects everywhere else. The policy was also never true of the code that stated it: ByteBuffer's own [] has always delegated to the value array's subscript, which panics. Reaching for an element out of range is a precondition violation — a bug in the caller — while a search that finds nothing is an ordinary outcome; the two deserve different mechanisms, and which class you are holding is not what should decide it.
(d.ii) Rule. A live view mutation-locks its source. ->view(start, length) exists on all six, the mutable pair included. While a view over a growable container is in scope, that container may not be mutated: a call to any MODIFY method of the source, or a rebind of the source variable, is a compile-time error (E5003). The lock is released when the view's binding leaves scope, so the idiom is to bound the view in its own block and mutate afterwards. This is the same per-source lock that has covered iterators since Bug #26, and it is a compile-time analyzer rule — no lock object, no runtime cost, no deadlock.
Rationale. A view holds a borrow of the container's storage, so a growth that reallocates, or a truncation that shortens, leaves a window describing a range the storage no longer has. Measured, the substrate survives reallocation and a later out-of-range read panics rather than corrupting — so this is not closing a corruption hole. It converts a runtime panic into a build-time diagnostic about a fact the compiler can already see, which is the trade this language makes everywhere else. What the lock does not cover is a view outliving its source; that remains the escaping-borrow restriction of (d) above, which is stated but not yet enforced.
(e) Rule. The substring search surface lives on the views, not the owning containers: ->find, ->contains, ->count, ->startsWith, ->endsWith, ->lastIndexOf, ->find_first_of, and ->find_first_not_of are all methods of StringView and ByteBufferView. The needle/set argument is the view's element array — char32[] for a StringView, binary[] for a ByteBufferView — and a needle String/ByteBuffer is presented by taking a full-span view of it and ->copy()-ing that to the element array. A StringView search offers a case-fold variant (->find(needle, caseMatch), likewise contains/count); a ByteBufferView does not, because a byte carries no case. A search that may find nothing (find, lastIndexOf, find_first_of, find_first_not_of) reports a miss through a pipe-XOR (int64 index | STATUS s) return, while startsWith/endsWith/contains return a plain boolean; a returned index is relative to the view it was found in. An out-of-range view(...) or operator[] on a view is a precondition violation and panics. The convenience search methods on String itself are thin forwarders to this one canonical scan.
Method (on StringView / ByteBufferView) |
Returns | Description |
|---|---|---|
->length() |
int64 |
element count of the window (code points / bytes) |
->view(int64 start, int64 length) |
a view of the same kind | narrow to a sub-window; bounds checked against this view, so a sub-view can never escape its parent |
->copy() |
char32[] / binary[] |
materialise the window into a fresh owning element array |
->find(needle) |
(int64 index \| STATUS s) |
first occurrence, or a FAILURE when absent; view-relative index |
->contains(needle) |
boolean |
whether needle occurs at least once |
->count(needle) |
int64 |
non-overlapping occurrence count; empty needle yields 0 |
->startsWith(needle) |
boolean |
prefix test; empty needle is TRUE |
->endsWith(needle) |
boolean |
suffix test; empty needle is TRUE |
->lastIndexOf(needle) |
(int64 index \| STATUS s) |
last occurrence, or a FAILURE when absent; view-relative index |
->find_first_of(set) |
(int64 index \| STATUS s) |
first index whose element is a member of set, or a FAILURE when none is |
->find_first_not_of(set) |
(int64 index \| STATUS s) |
first index whose element is not a member of set, or a FAILURE when every one is |
Example.
StringView hay := text->view(0, text.codePointLength) // full-span view makes indices absolute
char32[] ndl := needleString->view(0, needleString.codePointLength)->copy()
IF hay->find(ndl) THEN { int64 at := $RETURNED } // pipe-XOR miss report
ELSE { print("not found") }
boolean pre := hay->startsWith(ndl) // plain boolean
IF hay->find(ndl, caseMatch) THEN { ... } // StringView-only case-fold variant
Rationale. Hosting the search surface on the views gives one canonical scan that both columns and every convenience forwarder delegate to — the single source of truth for searching. Reporting a miss with pipe-XOR rather than a sentinel index means "not found" is a state the type system forces the caller to handle, not a magic -1 that can be used by mistake. A full-span view makes a view-relative index absolute in the underlying container.
Part I.D.vii — String
The built-in immutable text value type: its assignment rules, the always-present exception to EMPTY, its count methods, and its method surface.
(a) Rule. String is a built-in value type — neither a primitive nor a user-defined class, and it cannot be subclassed. Its content is immutable: every String method that appears to change a string in fact returns a new String, leaving the original untouched. The compiler manages a string's storage automatically; it is never EMPTY. String satisfies Cloneable (so it composes with the *Copy collection methods and with :=) and Hashable (so it may serve as a Dictionary key).
Example.
String greeting = "hello"
String shout := greeting->toUpper() // returns a NEW Uppercase String; greeting untouched
Dictionary[String, int32, DefaultHasher[String]] counts // String is Hashable — valid key
Rationale. Immutability is what makes a String safe to share and hash: nothing can mutate it out from under a holder, so Cloneable and Hashable hold unconditionally, and the "appears to change" methods returning fresh strings preserves that guarantee.
(b) Rule. String follows class-type assignment rules, and the assignment operator is meaningful. A string literal is assigned with =, and the compiler interns the constant. A string produced by a method call, expression, or another variable is assigned with := (an owned copy bound to the new binding), and using = there is a compile error (E2020). A String is never EMPTY: it is always present, and an empty string is the present value "", not absence. So assigning EMPTY to a String is a compile error, and a string field needs no empty initial state. ByteBuffer is immutable in the same way and follows the same always-present rule; both are the deliberate exceptions to the absence model of I.D.iv.
Example.
String name := "Bob" // literal — := is correct
String upper := name->toUpper() // expression — := required
String copy := name // variable — := required
// String bad = name->toUpper() // ERROR (E2020) — computed value needs :=
// String s := EMPTY // ERROR — String is never EMPTY; use "" for present-empty
String empty := "" // present empty string, not absence
Rationale. The operator encodes what actually happens: = binds an interned constant, := binds an owned copy, so E2020 fires when the two are confused. String/ByteBuffer are always-present because an empty string "" is a genuine value, not an absent state — making them the two deliberate exceptions to I.D.iv means string fields never need an empty-initial guard.
(c) Rule. There is no index operator on a string: name[0] is a compile error, because a code-point index into Unicode text is not the constant-time array access the bracket syntax implies. A string exposes two count methods — ->length(), the code-point count, and ->size(), the UTF-8 byte count — alongside the methods below. A grapheme count is reserved for V3, with the Unicode modes of I.D.iii. Where a method may legitimately produce no result, it does so through a pipe-XOR return rather than a ?-typed one.
| Method | Returns | Description |
|---|---|---|
->isEmpty() |
boolean |
TRUE when the length is zero |
->equals(other) |
boolean |
content equality — the correct (and required) test for two strings; ==/!= on a String is a compile error (E8013) |
->equalsIgnoreCase(other) |
boolean |
content equality, case-insensitive |
->contains(other) |
boolean |
substring test |
->startsWith(prefix) / ->endsWith(suffix) |
boolean |
prefix and suffix tests |
->find(other) / ->find(other, fromIndex) |
(int64 idx \| STATUS s) |
first-occurrence index, or a FAILURE when absent. indexOf was retired 2026-08-27: it was a second name for this search on the byte axis only, and it reported a miss with a -1 sentinel the language rejects everywhere else |
->lastIndexOf(other) |
(int64 idx \| STATUS s) |
last-occurrence index, or a FAILURE when absent |
->find_first_of(set) |
(int64 idx \| STATUS s) |
first index whose code point is a member of set (the code points of set, treated as a set), or a FAILURE when none is |
->find_first_not_of(set) |
(int64 idx \| STATUS s) |
first index whose code point is not a member of set, or a FAILURE when every code point is |
->toUpper() / ->toLower() |
String |
a new string with the case changed |
->trim() / ->trimStart() / ->trimEnd() |
String |
a new string with whitespace stripped |
->split(String delimiter) |
String[] |
split on a fixed delimiter |
->substring(int32 start, int32 length) |
String |
a new string holding the named range |
Example.
// name[0] // ERROR — no index operator on a String
IF name->charAt(0) THEN { char first := $RETURNED } // pipe-XOR — may be out of bounds
IF a->equals(b) THEN { ... } // content equality — required test
// IF a == b THEN { ... } // ERROR (E8013) — ==/!= on a String
int32 bytes := name.byteLength // count field, not a method
String[] parts := csv->split(",")
Rationale. The bracket syntax implies constant-time access, which a code-point index into variable-width Unicode is not — so it is disallowed, and indexing goes through ->charAt/->codeUnitAt which name the cost and the fallibility. Every may-fail method returns pipe-XOR (never a ?-type, which does not exist), so a caller cannot forget to handle the miss.
(d) Rule. Conversion of a String to or from any other type is the work of the AS/INTO operators of I.D.viii, not of a method on String. Pattern-based operations are not methods on String. Regex match, replace, and single-group captureGroup live on the Regex type in the separate, optional Regex module, and String carries no dependency on it. (Multi-group capture, captureNamed, and pattern split — which would return String[] / Dictionary — are deferred until arrays can cross the FFI boundary (ENVZN_IR_SPEC.md C.i).)
Example.
int32 n = 0
IF (name INTO int32) THEN { n = $RETURNED } // conversion is an operator, not name->toInt()
// regex work lives on the optional Regex module, not on String:
// Regex r := CREATE Regex("[0-9]+")
// IF r->match(name) THEN { ... }
Rationale. Keeping conversion off String and on the AS/INTO operators is what makes String's surface purely about text and lets the conversion rules live in one place (I.D.viii). Keeping regex in a separate optional module means a program that never pattern-matches carries no regex dependency — the String type stays lean.
Part I.D.viii — Numeric and type conversion (AS / INTO)
The two conversion operators — no casts — their loss and fallibility axes, and the named kernel hosts that carry the conversion families.
(a) Rule. Envzn permits no cast: there is no syntax that reinterprets one type as another. All conversion is written with the two operators AS and INTO: between numeric types, between numbers and text, and between the text and binary container columns of I.D.vi. This Part is their full specification — the declaration form in (d), the use site and its resolution in (e), the coherence rules in (f). A conversion is not a method call on a singleton: the former Convert gateway was retired in V1 (2026-06-07), its families redistributed into the named conversion hosts below.
Example.
String label := (42 INTO String) // conversion operator
// float64 d := (int64Val AS float64)* // no C-style cast syntax exists anywhere
// Convert.toString(42) // retired — the Convert gateway is gone
Rationale. A cast is an assertion the compiler is asked to trust; the language prefers a conversion the compiler can check. So the reinterpreting syntax simply does not exist, and every type change is a checked operator whose loss and fallibility are visible in the code.
(b) Rule. Two orthogonal properties shape every conversion. The loss axis is the operator keyword: INTO is lossless, AS is lossy. The fallibility axis is the return shape. A total conversion yields a plain value; a fallible one yields the pipe-XOR shape of I.M.i, consumed as the condition of an IF/WHILE whose body reads $RETURNED. From these follow three shapes. A widening numeric conversion — a narrow integer to a wider one of the same sign, or an integer to a float — is lossless-total, so INTO. A narrowing or sign-crossing conversion can lose data, so it is the range-checked lossy-fallible AS: a checked conversion, never a silent truncation. Parsing text into a number can always fail, so it is the lossless-fallible INTO from a String.
Example.
// parsing — lossless but may fail, so pipe-XOR consumed by IF
IF (userInput INTO int32) THEN { process($RETURNED) }
ELSE { printline($!) }
// number to text — lossless and total, a plain value
String label := (42 INTO String)
// widening — lossless and total
float64 d = (myInt32 INTO float64)
// narrowing float → int — lossy and range-checked, so AS + pipe-XOR
IF (myFloat64 AS int32) THEN { pixel = $RETURNED }
Rationale. Carrying loss on the keyword and fallibility on the return shape lets the four practical cases fall out mechanically — the reader sees INTO vs. AS and knows whether data can be lost, and sees a plain value vs. pipe-XOR and knows whether it must handle a failure. Range-checking AS rather than truncating is what turns a whole class of silent narrowing bugs into a handled STATUS.
(c) Rule. The conversion families the former Convert singleton carried now live as named kernel hosts. Each family has a named host, and all of them are CONVERSIONS hosts of AS/INTO operators. Value-to-String renderings are INTO String operators on NumberConverter, FloatConverter, and CharConverter. Numeric widenings and narrowings are PrimitiveConversions, the char-width crossings of I.D.i are CharWidthConverter, and the String↔ByteBuffer bridge of I.D.vi is TextConverter. A few operations are deliberately not checked conversions, and stay ordinary NAMESPACE method calls rather than operators: the truncating bit-exact casts on NumericUtilities, the hex and Base64 codecs (HexCodec, Base64Codec), the endian binary serialization (ByteOrderCodec), and the character predicates (CharClassifier). I.O catalogues the surface; (d)–(f) below are the normative operator spec.
Example.
String s := (myFloat64 INTO String) // FloatConverter host
char32 wide = (someChar8 INTO char32) // CharWidthConverter host
ByteBuffer bb := (text INTO ByteBuffer) // TextConverter host
uint64 raw = NumericUtilities.truncateToUint64(x) // NAMESPACE call — deliberately unchecked
String hex := HexCodec.toHex(bb) // codec is a NAMESPACE method, not an operator
Rationale. The checked, loss-visible conversions belong on CONVERSIONS hosts because they are exactly what AS/INTO model. The casts, codecs, and predicates are not checked conversions, so routing them through NAMESPACE calls keeps the operator surface meaning only "checked conversion" — never blurring into "reinterpret these bits."
(d) Rule. A conversion is declared in a top-level construct, CONVERSIONS Name { OPERATOR AS|INTO (FROM <source>) RETURNS <target> { … } }, or as an OPERATOR AS|INTO member on a class. RETURNS is mandatory. Beside its operators a CONVERSIONS block may declare PRIVATE METHOD helpers and CONSTANT fields, exactly as a NAMESPACE may (I.K.ii.d), so an operator body names its limits — INT8_MIN, NumericLimits.INT64_MAX — rather than spelling them as literals. The block is typeless, so there is no self to infer the target from, and the return doubles as the target lookup key and the fallibility marker: a bare RETURNS T declares a total conversion, a pipe-XOR RETURNS (T | STATUS) a fallible one. There is no LOSSY/LOSSLESS keyword: the operator keyword and the return shape already carry both axes. All four combinations are real — a lossless total count INTO String, a lossless fallible parse "42" INTO int32, a lossy fallible checked narrowing pi AS int32, and a lossy total conversion a developer writes for a knowing downsample. Because a conversion may be hosted wherever the developer owns the source or the target type, a conversion whose target is a kernel or primitive type lives in a neutral user CONVERSIONS block — the escape hatch the orphan rule of (f) leaves open.
Example.
CONVERSIONS TemperatureConversions {
OPERATOR INTO FROM Celsius RETURNS Fahrenheit { ... } // total — bare RETURNS
OPERATOR AS FROM Celsius RETURNS (int8 | STATUS) { ... } // fallible — pipe-XOR RETURNS
}
Rationale. A CONVERSIONS block lowers to a C++ namespace of inline free functions — declaration-only, never instantiated, never named at a use site — so it structurally cannot be the singleton it retires.
(e) Rule. At a use site, x AS T and x INTO T are expressions usable wherever a T is expected — an assignment right side, a RETURN, a call argument. A total conversion is a plain value; a fallible one follows the pipe-XOR rule of I.M, consumed as the condition of an IF/WHILE whose body reads $RETURNED. Conversions across a hard line must be written with AS/INTO: any text↔number, String↔ByteBuffer, or numeric narrowing/sign-cross. A bare assignment across one is a compile error. The soft line is numeric widening: a same-signedness integer to a wider integer, any integer to a float, float32 to float64. It stays implicit in a bare assignment and is a registered total INTO operator, so written explicitly, x INTO T resolves through the conversion registry like any other conversion. Resolution is keyed on the (source, target) pair. x INTO T requires a lossless conversion; using INTO where only a lossy one is declared is diagnostic E2087 ("use AS"). Symmetrically, x AS T requires a lossy conversion; writing AS where the declared conversion is lossless is diagnostic E2105 ("use INTO"). No declared conversion for the pair — including an explicit INTO/AS whose pair has no registered operator — is diagnostic E2086. Conversions do not chain: A→B and B→C never synthesize A→C.
Example.
String label := (count INTO String) // total — a plain value
IF ("42" INTO int32) { int32 n = $RETURNED } // fallible — pipe-XOR, read $RETURNED
IF (pi AS int32) { int32 n = $RETURNED } // lossy, range-checked — never a silent truncation
float64 wide = narrow // soft line — widening stays implicit
Rationale. The operator keyword always names the loss axis truthfully, so a reader who sees AS knows data may be lost and one who sees INTO knows it cannot be. A missing pair is caught here rather than at code generation, because an emitter has no intrinsic conversion path to fall back on. The no-chaining rule is what keeps resolution decidable.
(f) Rule. The conversion surface is a coherent, owned registry, and two declaration-site rules keep it sound across modules. Coherence (E2088): a (source, target) pair may be declared only once across a module and its dependencies. A redefinition is a major error naming the pair and where it was previously defined — exact file:line for an in-module prior, the host and module for a dependency prior. Orphan rule (E2089): a CONVERSIONS OPERATOR for (source, target) may be declared only by the module that owns the source or the target type. Primitives and kernel types are owned by the kernel module (ENVZN), so only the kernel may declare primitive↔primitive and primitive↔kernel-class conversions.
Rationale. Together these mean x INTO T has exactly one meaning everywhere — no module can silently shadow another's conversion — mirroring the trait-coherence and orphan rules of typeclass systems.
Part I.D.ix — Type naming and the reserved surface
The PascalCase convention for user types and the rule that no module may name — or shadow — a kernel type.
(a) Rule. A user-defined type is named in PascalCase by convention, and the naming rules of I.C apply to it as to any identifier. A module may not declare a type whose name coincides with any kernel (ENVZN) type — neither the utility gateways — System, Stdio, Process, Math, File, Path, and the retired Convert — nor the collection and value types String, Array, Set, Dictionary, Box, DynamicString, and the rest. An attempt to do so is a compile error. Kernel types are not shadowable. I.N gives the resolution order, and the Appendix catalogues every reserved identifier.
Example.
CLASS InvoiceLine { ... } // PascalCase user type — fine
// CLASS Set { ... } // ERROR — Set is a reserved kernel type name
// CLASS Dictionary[K, V, H] { ... } // ERROR — cannot shadow a kernel type
// NAMESPACE Math { ... } // ERROR — Math is a reserved gateway name
Rationale. Allowing a module its own Set, with the kernel's still reachable as ENVZN::Set, silently corrupts type resolution: a concrete field in the shadowing class can be mis-typed as the shadowed kernel generic's T, and the emitter lowers it wrongly. The rule is therefore uniform — every kernel type name is reserved. Convert keeps its reservation even though its gateway is gone, so old code naming it fails loudly rather than resolving to a user type.
Part I.D.x — The C-ABI boundary types
(a) Rule. Four type names exist solely to name a C type at the foreign-function boundary: C.size_t, C.ssize_t, C.long, and C.unsignedLong. Each lowers verbatim to the C spelling it names. Each is a single reserved word that contains a dot — C is not a namespace, declares nothing, and reserves no identifier; a class, module, or variable named C remains perfectly legal.
These types are admissible only inside a FOREIGN or FOREIGN BIND declaration. They may not be the type of a local, a field, a collection element, or a return of an ordinary method. They carry no arithmetic and no ordering, they are not members of the Numeric group, and they are not valid dictionary keys. They have no guaranteed width — that absence is their contract, not an omission. A caller passes and receives an ordinary Envzn integer; the compiler converts at the boundary, and an inbound value outside the platform's range for that C type raises the MathError of I.F.ii(a.iii) rather than truncating.
Example.
FOREIGN BIND EVP_DigestSign(EvpMdCtx ctx, LOAD uint8[] sig,
LOAD C.size_t siglen,
uint8[] tbs, C.size_t tbsLen) RETURNS int32
uint64 siglen = 256 // an ordinary Envzn integer
UNSAFE { rc = FOREIGN::EVP_DigestSign(ctx, sig, siglen, tbs, tbsLen) }
// siglen now holds the length the C function wrote back
// C.size_t n = 0 // ERROR — not a declarable type
// CLASS C { ... } // fine — `C` is not reserved
Rationale. Envzn's fixed-width primitives lower to the exact-width <cstdint> types, which is right for Envzn's own semantics and wrong for naming a C typedef. On a 64-bit platform size_t and uint64_t occupy the same width yet are distinct types, and C++ refuses to convert between pointers to them — so a size_t * out-parameter cannot be expressed by any fixed-width primitive. Worse, which primitive would happen to match flips between platforms: uint64_t is unsigned long long on Darwin and unsigned long on Linux, so a binding written against the wrong one compiles on one target and fails on the other. Naming the C type makes the match true by construction everywhere, which is why the family names C's spelling rather than a width. The boundary-only restriction is what keeps that platform-variable width from ever entering Envzn storage, where it would reintroduce exactly the silently-wrong-size defect the memory-safety floor forbids.
Section I.E — Declarations and Bindings
How a name is given a type and storage — locals, fields, parameters, and constants — together with the one-new-declaration-per-line rule, the no-shadowing rule, and the modifiers that adjust a field.
(a) Rule. A declaration introduces a name, gives it a type, and provides it with storage. Envzn has four kinds of declared binding: local variables, class fields, method parameters, and constants. Their deeper semantics differ, but they share one surface form — the type precedes the name, and the name follows the identifier rules of I.C. Every local variable, every field, and every parameter is written with its type stated. Type inference operates within a declaration, resolving the types of literals and sub-expressions on the right-hand side against the declared type on the left; it is not a way to omit the type.
Example.
int32 count = 0 // local variable
int64 total = 3 + 4 // literals 3, 4 typed int64 against the declared type
String name = "Ada" // String literal binds with =
Circle c := CREATE(5.0) // local, ownership bind (concrete type inferred)
Rationale. The type at a binding site is exactly the information a later reader needs, and the language declines to make that reader reconstruct it. Inference resolves the right-hand side against the stated type rather than replacing it, so the surface never omits the one fact the reader came for.
(b) Rule. A local variable is declared inside a method body or block. It is initialized at the point of declaration with one of the three assignment operators described in I.G: = to bind a value, := to take ownership of an object (construct, clone, or move), =@ for a non-owning reference. The operator is part of the declaration's meaning and not a stylistic choice; I.G gives the rules that pair an operator with the shape of binding it may introduce.
Example.
int32 n = readCount() // = binds a value (primitive)
String label := computeLabel() // := computed String takes ownership
Buffer b := CREATE Buffer(1024) // := construct → ownership
REFERENCE Node r =@ .head // =@ non-owning reference to a place
Rationale. Because the operator carries the meaning, a reader can tell a value-copy from an ownership transfer from a borrow without reading the right-hand side or the type. The pairing rules live in I.G so the operator alone is enough to know what a binding does.
(c) Rule. An assignment line may declare at most one new typed variable. When a method returns several values, the additional receiving variables are declared on their own lines first, and only the final new variable is introduced on the assignment line itself.
Example.
// correct — one new declaration on the assignment line; STATUS s declared above
STATUS s
int32 n, s = scanInt(text) // a comma-shape (int32, STATUS) producer
// compile error — two new declarations on one assignment line
int32 n, STATUS s = scanInt(text)
Rationale. A reader scanning a line of code can see at a glance which names it brings into existence; predeclaring the extra receivers keeps that count to one on the line where the values arrive.
(d) Rule. A declaration may not shadow a name already visible to it. A local variable whose name matches a class field, a method parameter, a return variable, or any local already declared in an enclosing scope is a compile error. Envzn permits no shadowing at any level.
Example.
CLASS Account {
int32 balance // field
METHOD adjust(int32 delta) RETURNS STATUS {
int32 balance = delta // ERROR — shadows the field `balance`
int32 delta = 0 // ERROR — shadows the parameter `delta`
}
}
Rationale. A name that silently means two things in one method is a reliable source of error and offers nothing in exchange, so the language forbids the collision outright rather than resolving it by scope.
(e) Rule. A class field is declared inside a class body, and a field is public and immutable unless a modifier says otherwise: visibility narrows only where the declaration says so, while mutability widens only through a MODIFY method. The modifiers that adjust a field are the access modifiers — PRIVATE, PROTECTED, and the module-scoped INTERNAL — together with three that adjust storage and lifetime: SHARED exposes a field to subclasses for reading, and CONSTANT declares a field whose value is fixed at compile time. (The VOLATILE field modifier was retired in V1, 2026-05-13; the keyword stays reserved for a future runtime-checked observer — see I.I. A V1 field is mutated through MODIFY methods, and shared mutable state across tasks goes through Mutex[T] / AtomicX / a SHARED CLASS monitor, not a raw VOLATILE field.)
Example.
CLASS Point {
int32 x // public + immutable by default
PRIVATE int32 y // confined to Point
PROTECTED int32 z // visible to subclasses
SHARED int32 w // readable by subclasses
INTERNAL int32 tag // module-scoped
CONSTANT int32 ORIGIN = 0 // compile-time-fixed value
}
Rationale. The default a developer gets for free is the one that needs no ceremony to read, so visibility opens and every narrowing of it is spelled out with a modifier — the audience is named exactly where it is restricted, and a declaration carrying no modifier makes no claim to hide. Safety rides the other axis: a field is immutable by default and every write path goes through a named MODIFY method, so the guarantee that matters — nothing mutates the receiver behind your back — never rested on the visibility default. The class as a whole, its INIT, its methods, and its inheritance are the subject of I.K. The ownership a field declaration implies is described in I.I.
(e.i) Rule. A primitive field, and a String or ByteBuffer field, is auto-initialized to its default before INIT runs — the two containers because they are the always-present exceptions of I.D.vii and can never be EMPTY. Any other class-typed field is not: the compiler requires it to be assigned on every path through INIT, and an unset class-typed field is a compile error rather than a silent EMPTY.
Example.
CLASS Widget {
int32 count // auto-initialized to 0 before INIT
String name // auto-initialized to "" before INIT
ByteBuffer blob // auto-initialized to empty before INIT
Buffer buf // class-typed — NOT auto-initialized
INIT() {
.buf := CREATE Buffer(64) // must be assigned on every path, or compile error
}
}
Rationale. Primitives, String, and ByteBuffer have a well-defined default, so the compiler can supply it silently. An ordinary class instance has none, and leaving one unset would be an absent object masquerading as present. Forcing the assignment turns a would-be silent EMPTY into a compile error at the one place it can be fixed.
(e.ii) Rule. The auto-initialization of a String or ByteBuffer field is a semantic guarantee, not a promise about when the storage is built. The value is "" from the moment the object exists: an unassigned field is observably an empty string, never absent and never a fault. But the compiler is free to materialize that empty value lazily, at the field's first read, rather than constructing one in every constructor. A field the developer's own INIT assigns therefore costs exactly one construction, not two, and a field that is never read costs nothing at all.
Example.
CLASS Person {
PRIVATE String name // never assigned by INIT …
INIT() { }
METHOD greet() RETURNS String {
RETURN (("Hello, $1")->format(.name)) // … and reads as "" here, not a fault
}
}
Rationale. This is the I.A tie-break — relocate the cost inward — applied to the most common field type in the language. Eagerly default-constructing every String field would put an allocation in every constructor for a value the developer usually overwrites on the next line, and every object in the program pays it. Deferring it keeps the developer's surface exactly as the rule states: a String field is always there. The machinery that makes it true lives in the compiler and the kernel. (Dropping the guarantee instead of relocating it is what goes wrong: the field is left as a null handle, and reading it faults with no diagnostic and no way to guard, since a String cannot be EMPTY and IS VALID does not apply to one.)
(f) Rule. A CONSTANT field is set at its declaration with a literal right-hand side, may hold only a primitive or String value, and may never be reassigned. It composes freely with PRIVATE, SHARED, and INTERNAL, or may stand with no access modifier at all — in which case it is public; the order of CONSTANT relative to the access modifier does not matter. It does not compose with PROTECTED, REFERENCE, or FOREIGN_TYPE, and each such combination is a compile error. (VOLATILE was retired as a field modifier in V1 and is rejected on any field, CONSTANT or not; the keyword itself stays reserved for future use.)
Example.
CONSTANT int32 MAX = 100 // no access modifier → public
PRIVATE CONSTANT int32 SEED = 42 // composes with PRIVATE
CONSTANT PRIVATE int32 SEED2 = 42 // order of CONSTANT vs modifier does not matter
SHARED CONSTANT String NAME = "envzn" // String value; composes with SHARED
// errors — CONSTANT does not compose with these
VOLATILE CONSTANT int32 v = 1 // ERROR — runtime mutability contradicts a fixed value
PROTECTED CONSTANT int32 p = 1 // ERROR — PROTECTED does not compose with CONSTANT
REFERENCE CONSTANT int32 r = 1 // ERROR — a borrow storage shape contradicts a fixed value
Rationale. PROTECTED, REFERENCE, and FOREIGN_TYPE each describe a storage shape or visibility rule that contradicts a compile-time-fixed value, so the combination is rejected rather than silently reinterpreted. The modifiers that do compose only adjust visibility, which a fixed value tolerates.
(g) Rule. A method parameter is declared in a method or INIT signature, type before name, exactly as a local is. Every call is positional and supplies every argument: a parameter may not carry a default value, and there is no named-argument call syntax. A method that would want an omitted argument is spelled as two overloads, or as a method taking a value the caller computes.
Example.
METHOD greet(String name, int32 times) RETURNS STATUS { ... } // type before name, as a local
obj->greet("Ada", 3) // positional — every argument supplied
Rationale. Every argument being written at every call site keeps a call readable without consulting the signature: what is passed is what is there. It also keeps one call shape rather than two, so a reader never has to know whether a given argument was omitted or defaulted. The ownership a parameter declaration implies, and what a callee may do with each parameter shape, are given in I.I.
Section I.F — Expressions
The forms that compute a value — member access, operators, object construction, method calls, lambdas, and format strings — and the rule governing each.
(a) Rule. An expression computes a value. This section covers the forms that do so — operators, member access, object construction, method calls, lambdas, and format strings — and states the rules of each. The coalescing operator ??, which is also an expression, is defined with the absence model in I.D.iv and not repeated here.
Example.
int32 sum = a + b // operator expression
String name := person.name // member-access expression
Circle c := CREATE(5.0) // object-construction expression
Identity id := IDENTITY(person) // compile-time reflection expression (I.K.iii)
int32 len = items->count() // method-call expression
String label := ("$1 of $2")->format(i, n) // format-string expression
int32 n = maybeCount ?? 0 // coalescing — see I.D.iv, not here
Rationale. Naming the closed set of value-producing forms up front lets each subpart specify one form in isolation. ?? lives with the absence model it serves rather than being duplicated here, so there is a single source of truth for how absence coalesces.
Part I.F.i — Member access
Two sigils, chosen by what you hold: . reaches into a value or scope, -> sends a message to an instance.
(a) Rule. A . accesses a data field and a NAMESPACE member; no instance handle is involved. A . reaches a declared data field (person.name), a struct field, a builtin-array field (myArray.length — a T[] carries its length as real data), an iterator-state field (iter.index), and a NAMESPACE function or constant (Math.PI, Math.sqrt(2.0)). A namespace has no instance, so its functions and constants are reached directly, like any scoped name. The compiler synthesizes no phantom computed field (I.C.c.i). A quantity a class exposes through a method — a length, a size, a count computed from its contents — is read ->length() / ->size(), never .length / .size.
Example.
String who := person.name // data field
int32 len = myArray.length // synthesized field
int32 at = iter.index // iterator-state field
float64 pi = Math.PI // NAMESPACE constant
float64 root = Math.sqrt(2.0) // NAMESPACE function
Rationale. A namespace is a scope, not an object; there is nothing to dereference, so its members are reached the same way as any other named-scope member.
(b) Rule. A -> invokes a method on an instance or actor: person->getName(), items->append(x), System->exit(0). The rule is uniform across instance and inherited methods, the kernel collection methods, and SINGLETON actor methods — all ->.
Example.
String n := person->getName() // instance method
items->append(x) // kernel collection method
System->exit(0) // SINGLETON actor method
Rationale. A reader should be able to tell, from the sigil alone and without consulting a declaration, whether a fragment reaches into a value/scope or sends a message to an instance.
(c) Rule. The compiler holds the line in every direction. person.getName() is an error: an instance method reached with the field sigil. person->name is an error: a field reached with the method sigil. Math->sqrt(2.0) is an error (E1106) because a namespace function is reached with the instance sigil, and a namespace has nothing to dereference.
Example.
String n := person.getName() // ERROR — instance method reached with the field sigil
String m := person->name // ERROR — data field reached with the method sigil
float64 r = Math->sqrt(2.0) // ERROR (E1106) — namespace function reached with the instance sigil
Rationale. Enforcing the distinction in both directions is what makes the sigil a reliable signal: if the compiler allowed either form to slip, the reader could no longer trust it.
Part I.F.ii — Operators
English keywords for most operators; punctuation reserved for arithmetic and comparison where a symbol is universally understood.
(a) Rule. The arithmetic operators are the five ordinary ones (+ - * / %) together with two Envzn adds: ^ raises to a power, and # combines the hashes of two primitive operands into a single int32. ^ is right-associative and binds tighter than * and /, and tighter than unary minus on its left — so -2 ^ 2 is -(2 ^ 2). Its result is an integer when both operands are integers and the exponent is provably non-negative: a non-negative literal, or an unsigned-typed exponent. Otherwise — a float operand, or an exponent that could be negative — the result is float64. Overflow does not wrap silently — it raises a MathError PANIC, as for * (the overflow-and-range contract of (a.iii)). The # operator requires both operands to be primitive and has no unary form; being a hash fold, # wraps by design and is the one arithmetic operator exempt from the overflow PANIC of (a.iii).
Example.
int32 sq = -2 ^ 2 // -(2 ^ 2) == -4 (^ binds tighter than unary minus)
int32 cube = 2 ^ 3 // 8 (both operands int, exponent non-negative → int)
float64 inv = 2 ^ -1 // 0.5 (exponent could be negative → float64)
int32 h = keyA # keyB // hash-combine of two primitives → int32
Rationale. A negative exponent has no integer result, so the type widens to float64 exactly when the exponent is not provably non-negative. ^'s tight binding matches the mathematical reading of -2^2, and # is restricted to primitives because it combines their raw hashes.
(a.i) Rule. The postfix ! is the factorial operator: 5! is 120. It applies to an integer operand only; a float operand is a compile error, the gamma function being reserved for V2. It binds tighter than ^, so 2 ^ 3! is 2 ^ (3!), and follows the empty-product convention, so 0! is 1. On overflow it raises a MathError PANIC (the contract of (a.iii)) rather than wrapping silently.
Example.
int32 f = 5! // 120
int32 one = 0! // 1 (empty-product convention)
int32 p = 2 ^ 3! // 2 ^ (3!) == 2 ^ 6 == 64 (! binds tighter than ^)
int32 bad = 3.0! // ERROR — factorial on a float operand (gamma reserved for V2)
Rationale. Factorial binds tighter than ^ so the common 2 ^ n! reads without parentheses, and it is integer-only because the float generalization (the gamma function) is deliberately held back to V2.
(a.ii) Rule. Ordinary division / is integer division when both operands are integers — 7 / 2 is 3 — and yields a float64 as soon as either operand is a float, so 7.0 / 2 is 3.5. The ~/ operator is the explicit floor-division form. It always yields an integer-shaped value — the largest integer not exceeding the true quotient — flooring toward negative infinity rather than truncating toward zero, so -7 ~/ 2 is -4. Its domain violations panic rather than yield a silent wrong value — division by zero, a NaN or infinite float operand, and an integer-shaped result outside the int64 range each panic with MathError.
Example.
int32 a = 7 / 2 // 3 (both int → integer division)
float64 b = 7.0 / 2 // 3.5 (a float operand → float64)
int32 c = -7 ~/ 2 // -4 (floors toward negative infinity, not toward zero)
int32 d = 5 ~/ 0 // panics with MathError — division by zero
Rationale. ~/ is spelled ~/ rather than the // some languages use because // opens a line comment in Envzn. Floor division panics on domain violations rather than returning a silent wrong value, so a broken computation surfaces at the point it goes wrong.
| Operator | Operation | Operator | Operation | |
|---|---|---|---|---|
+ |
addition | % |
modulo (integer or float by operands) | |
- |
subtraction | ^ |
exponentiation | |
* |
multiplication | # |
hash-combine (primitive operands) | |
/ |
division (integer or float by operands) | ! |
factorial (postfix, integer operand) | |
~/ |
floor division (integer result) | &+ &- &* |
wrapping add / subtract / multiply (a.iv) |
(a.ii.a) Rule — the domain of %. Modulo is defined on the fixed-width integers and on
float32/float64, mirroring /. The float form is C's fmod: the result takes the sign of
the dividend and satisfies a == trunc(a/b)*b + (a % b) exactly, introducing no rounding error of
its own — so -7.5 % 2.0 is -1.5, not 0.5. The two divisor-zero behaviours differ because the
two types' overflow philosophies do (a.iii): an integer % by zero raises the tier-2 MathError
PANIC, while a float % by zero yields NaN, floating-point being IEEE-untrapped.
Modulo on complex has no accepted meaning — the complex numbers carry no ordering to take a
remainder against — and is rejected.
number and decimal128 are intended to support % and do not yet (gh #223). Both declare
+ - * / and stop there, because % is the one arithmetic operator with no operator-method
spelling for a class to declare, so neither can define it. For decimal128 the operation is exact
and needs no floating-point machinery — align the exponents and take the remainder of the
coefficients — and it is the operation a money type owes its callers: an allocation waterfall
splitting an amount N ways distributes the residual with %. The result's exponent rule is settled
with that work.
(a.iii) Rule — the overflow and range-violation contract. A bare arithmetic operator whose result leaves its type's representable range does not wrap silently — it raises a MathError PANIC. The PANIC is tier-2: it unwinds the stack running CLEANUP and is catchable by a top-level RECOVER for graceful shutdown — not an ASSERT!-tier abort that halts without teardown. The contract is uniform across + - *, unary -, ^, and ! on every fixed-width integer, signed and unsigned alike (int8..int128, uint8..uint128). A uint32 that overflows PANICs exactly as an int32 does: a silently-wrapped unsigned value — the classic wrapped size — is precisely the exploit the floor forbids. The same tier-2 PANIC covers integer division-by-zero (/ and %) and a shift past the operand's bit width. The one arithmetic operator exempt is # (hash-combine): hashing is modular by definition, so # folds its operands with defined wraparound and never PANICs. This contract is always on — it is part of the correctness floor, not a debug-only check, so it is not stripped under -prod (unlike the tier-3 ASSERT! family); a production build keeps its graceful-shutdown-on-overflow guarantee. There are exactly two documented type exceptions, each carrying its own overflow philosophy at its own type: number promotes up the INTEGER → UNSIGNED → FLOAT ladder (I.D.i(g.iv)) to preserve the mathematical value, so it never PANICs on integer overflow. decimal128 rounds within its precision (round-half-even), and PANICs only on true divide-by-zero or exponent overflow. Floating-point types are untrapped. float16/float32/float64 follow IEEE 754 exactly: an operation that leaves the finite range yields ±Infinity, and one that underflows yields a signed zero. Those are the type's defined results, not out-of-range conditions. Code that genuinely wants modular wraparound reaches for the first-class, greppable wrapping operators &+ &- &* (a.iv); silent wrap is never the operator default.
Example.
int32 big = 2_000_000_000 + 2_000_000_000 // PANIC (MathError) — int32 overflow, never a silent wrap
int32 wrap = 2_000_000_000 &+ 2_000_000_000 // explicit modular wrap — the greppable opt-in, no PANIC
float64 huge = 1e308 * 10.0 // Infinity — float is IEEE-untrapped, no PANIC
int32 z = 5 % 0 // PANIC (MathError) — integer divide-by-zero
Rationale. A silently-wrong number is the classic C footgun. A size = a * b that wraps to a small value and is then used to allocate or index is exactly how memory-safe code is subverted, so the memory-safe floor makes the natural operator refuse to produce it. The cost is relocated inward. The wrap philosophy lives in the explicit &+ &- &* operators (a.iv) rather than the default, and the runtime check falls on bare fixed-width integers alone — number, complex, and decimal128 are value-class-backed and carry it nearly for free. PANIC rather than abort is deliberate. A range violation cannot let the computation continue, but a server or fintech process must still catch it at a boundary, flush logs, close sockets, and exit cleanly. The CLEANUP-unwinding, RECOVER-catchable tier-2 gives exactly that, where a bare abort would leak resources on the way out. (Policy confirmed 2026-07-07. The checked lowering IS this contract's implementation and it has landed, so the -fwrapv soundness stopgap — defined two's-complement wrap, the explicit opposite of this rule — is removed, from the emitter (Bug #290) and, since 2026-08-26, from every remaining build. The hand-written C units in kernel/native/ carry the equivalent explicitly via EV_ckarith_native.h, which reports an overflow rather than defining it. Nothing in the language now relies on signed overflow being defined.)
(a.iv) Rule — the wrapping operators. The modular-arithmetic escape hatch of (a.iii) is a family of three operators — &+ (wrapping add), &- (wrapping subtract), &* (wrapping multiply) — spelled with a leading &. On a fixed-width integer they compute the true result modulo 2ⁿ and never PANIC: a &* b is the low-n bits of the full product, the defined two's-complement wrap. They are integer-only — a float, number, complex, or decimal128 operand is a compile error (E2134), since those types carry their own overflow philosophy (a.iii). &+/&- bind at additive precedence and &* at multiplicative, exactly like their panicking counterparts. This is the only place silent modular arithmetic is sanctioned, and it is loud at the use site — a reader sees &* and knows the wrap is intentional. Each lowers to a single machine instruction, so hashing / PRNG / checksum code pays nothing for the safety of the default.
Example.
uint64 mixed = acc &* prime // FNV mix — wraps mod 2^64, no PANIC
uint32 idx = (h &+ step) BAND mask // ring index — wrapping add, then mask
int64 bad = 3.0 &* x // ERROR (E2134) — wrapping ops are integer-only
Rationale. An operator, not a method: it carries no namespace reference, so it reads as arithmetic and introduces no spurious module/class dependency (a Math.wrap* call would). The & leader follows the established systems-language convention (Swift's &+ &- &*), is visually distinct from the panicking + - *, and greps cleanly, so every deliberate wrap is auditable.
(b) Rule. Increment and decrement apply to a mutable numeric variable, in both prefix and postfix position. The prefix forms ++i and --i change i and yield the new value; the postfix forms i++ and i-- yield the current value and then change i. Applying either to a literal, a constant, or a compound expression is a compile error, because there is no storage to update.
Example.
int32 i = 0
int32 a = ++i // i becomes 1, a == 1 (prefix: change then yield)
int32 b = i++ // b == 1, i becomes 2 (postfix: yield then change)
int32 c = ++5 // ERROR — no storage to update (literal)
int32 d = ++(a + b) // ERROR — no storage to update (compound expression)
Rationale. Increment and decrement mutate storage in place; a literal, constant, or compound expression names no storage, so there is nothing to write back.
(c) Rule. The comparison operators are the six ordinary ones — ==, !=, >, <, >=, <= — together with BEQUALS and BNEQUALS, which compare bit patterns rather than values. The ordinary == and != compare primitive values directly.
Example.
IF a == b THEN { ... } // primitive value equality
IF x >= 0 AND x < n THEN { ... } // ordinary comparisons on primitives
IF f1 BEQUALS f2 THEN { ... } // bit-pattern equality (distinguishes NaN, -0.0)
Rationale. Value comparison is the common case, so it gets the ordinary symbols; bit-pattern comparison is a distinct, rarer intent and gets its own named operators.
(c.i) Rule. == and != do not do what a reader from another language might expect on a String or a class instance. On a String, == and != are a compile error (diagnostic E8013): a reference comparison there is almost never intended, so the language refuses it and directs the reader to the content test ->equals(). On a class instance, == compares references (identity); content equality is the Equatable interface's ->equals(), and == on a module's own class is likewise rejected (E2069) in favour of a named method.
Example.
IF s1 == s2 THEN { ... } // ERROR (E8013) — String ==; use s1->equals(s2)
IF s1->equals(s2) THEN { ... } // content comparison of Strings
IF a == b THEN { ... } // class instance: reference identity
IF a->equals(b) THEN { ... } // content equality via the Equatable interface
Rationale. A reference comparison on a String is almost never what the writer meant, so the language refuses it outright rather than silently doing the surprising thing. The same reasoning rejects == on a module's own class, in favour of a named, greppable method.
(c.ii) Rule. On a float operand == is exact IEEE value equality, with no built-in tolerance: NaN == NaN is FALSE, 0.0 == -0.0 is TRUE, and 0.1 + 0.2 == 0.3 is FALSE. It is the same value comparison a number's == performs across its subtypes. For a principled tolerance there is the approximate-equality pair ≈ (U+2248) and its negation ≉ (U+2249), a number-only relational operator at the same precedence as ==: a ≈ b is TRUE when |a − b| ≤ max(rtol·max(|a|,|b|), atol) — a relative tolerance with an absolute floor so comparison to zero works. Because a number knows its subtype, two integer-subtype operands compare exactly (5 ≈ 6 is FALSE), and the tolerance applies only when a float subtype is involved. Any int*/float* operand widens into number, so n ≈ 3.0 compiles, while a non-numeric operand is a compile error. A custom tolerance is supplied through the companion method a->isClose(b, rtol, atol). There is no settable global tolerance, and ≈ has no ASCII spelling (an input method expands \approx/\napprox to the glyph; ~= is deliberately not reused, as it means not-equal elsewhere).
Example.
boolean e1 = NaN == NaN // FALSE (exact IEEE)
boolean e2 = 0.0 == -0.0 // TRUE
boolean e3 = 0.1 + 0.2 == 0.3 // FALSE (no built-in tolerance)
boolean e4 = a ≈ b // TRUE when |a − b| ≤ max(rtol·max(|a|,|b|), atol)
boolean e5 = 5 ≈ 6 // FALSE (integer subtypes compare exactly)
boolean e6 = n ≈ 3.0 // compiles — int* / float* widens into number
boolean e7 = a->isClose(b, rtol, atol) // custom tolerance via the companion method
Rationale. Exact IEEE equality is what a float == must mean to stay predictable, so principled tolerance is a separate operator rather than a hidden fuzz on ==. ≈ is number-only because tolerance is only meaningful once a subtype is known; there is no global tolerance knob because a hidden, mutable epsilon would make every comparison non-local.
(c.iii) Rule. Where the intent is instead bit-exact float comparison — distinguishing NaN from NaN and 0.0 from -0.0 — that is BEQUALS/BNEQUALS. The boolean operators are the keywords AND, OR, NOT, and XOR. The bitwise operators are likewise keywords — BAND, BOR, BXOR, and the ones-complement BNOT — as are the shift and rotate operators LSHIFT, RSHIFT (logical, zero-filled), ASHIFT (arithmetic, sign-filled), LROTATE, and RROTATE.
Example.
IF ready AND NOT paused THEN { ... } // boolean keywords
IF (a > 0) XOR (b > 0) THEN { ... } // boolean XOR
int32 masked = flags BAND 0x0F // bitwise AND
int32 flipped = BNOT flags // ones-complement (unary)
int32 hi = value LSHIFT 4 // logical left shift (zero-filled)
int32 lo = value RSHIFT 4 // logical right shift (zero-filled)
int32 signed = value ASHIFT 4 // arithmetic right shift (sign-filled)
int32 rot = value LROTATE 8 // rotate left
Rationale. Writing the bitwise and shift operators as keywords keeps them visually distinct from the arithmetic operators and removes the precedence guesswork that the C-style symbols invite.
(c.iv) Rule. A shift count must lie in [0, W), where W is the declared bit width of the shifted operand — int8 is 8, int32 is 32, uint64 is 64. Shifting significant bits off the end is legitimate and untouched; it is the count that is policed. A count the compiler can prove out of range is a compile error (E2130). A count it cannot prove is checked at runtime and raises a MathError — a PANIC, so it unwinds and FINALLY/CLEANUP run — rather than producing an undefined result. A count the compiler proves in range is emitted with no check at all. Separately, a bare integer literal shifted by a count that is not a provable constant is a compile error (E2131). A bare literal is int32, so 1 LSHIFT p silently shifts at 32-bit width no matter how wide its surroundings are; the literal must be given an explicit type.
Example.
int32 a = 5
int32 b = a LSHIFT 40 // E2130 — 40 >= 32, the width of int32
int8 c = 5
int8 d = c LSHIFT 10 // E2130 — 10 >= 8, the width of int8
int64 e = 5
int64 f = e LSHIFT 40 // fine — 40 < 64
uint64 mask = 1 LSHIFT p // E2131 — the literal `1` is int32, so this shifts at 32-bit
// width even though the target is 64-bit
uint64 one = 1
uint64 ok = one LSHIFT p // correct — the operand is genuinely 64-bit (count checked
// at runtime, since `p` is not provably in range)
Rationale. C leaves an over-width shift undefined, and a language whose floor is "memory safe — no crashes" (I.A) cannot inherit that: an under-specified shift is how a corrupted control byte became an infinite loop. The rule follows the same prove-or-check discipline as the array bounds check and HOTLOOP (I.H.iii(f)): prove it safe and pay nothing, prove it wrong and refuse to compile. Only where neither is provable does a runtime check appear. The width is the operand's declared width rather than C's promoted width because that is what a reader means: you cannot shift a value by more than its own size. E2131 exists because the bare-literal case is invisible — the expression looks 64-bit and is not.
(d) Rule. Arithmetic and comparison are defined on the numeric primitives, on String for equality and ordering, and on any user-defined type that opts in by implementing the operator interfaces. A type opts in one of two ways, and both keep an operator traceable to a named, greppable method — an operator never dispatches to something a reader cannot find by name.
Interface derivation is the default: implement one or more of the four reserved interfaces Equatable, Comparable, Arithmetic, and Multiplier, and the compiler derives the operators from the interface methods. The interfaces and the derivation are described in I.K.iv.
A direct METHOD operator declaration is the alternative, for a type whose operator cannot be expressed as an interface method. The declarable set is +, -, *, /, ~/, ^, <, >, <=, >= and []; a class declaring one is valid for that operator, and declaring operator < alone is enough for all four orderings — the compiler derives >, <= and >= from it, so a single method carries the ordering logic. % and the unary operators are not declarable (E2061), and ==/!= on a class stay reserved to Equatable's ->equals() (E2069).
The direct form exists because a partial operator cannot be an interface method. Money's ordering has no answer across currencies, so it needs two doors onto the same comparison: a < b answers boolean and PANICs on a mismatch, while a->isLessThan(b) returns a pipe-XOR (boolean | STATUS) the caller can handle. Comparable's isLessThan returns a bare boolean, and a pipe-XOR cannot be the result of an operator sitting in an IF, so the interface route cannot express the recoverable door. It is the same reason Money implements neither Arithmetic nor Multiplier: those interfaces presume total operations, and adding USD to EUR is not one. Applying an arithmetic operator to a char — other than the offset and distance shapes I.D.i(c.ii) admits — or any operator to an object that does not implement the relevant interface, is a compile error.
Example.
// ROUTE 1 — interface derivation, for a TOTAL operator.
VALUE CLASS Meters IMPLEMENTS Cloneable, Comparable[Meters], Equatable[Meters] {
float64 length
}
IF m1 < m2 THEN { ... } // derived from Comparable.isLessThan()
boolean same = m1->equals(m2) // Equatable.equals()
// ROUTE 2 — a direct `METHOD operator`, for a PARTIAL one. Money's ordering has
// no answer across currencies, so it needs both doors onto the same comparison.
VALUE CLASS Money IMPLEMENTS Cloneable {
decimal128 value
CurrencyCode code
METHOD operator <(Money other) RETURNS boolean { // the bare door
IF .code != other.code THEN {
PANIC MathError("Money: cannot order different currencies")
}
RETURN (.value < other.value)
}
METHOD isLessThan(Money other) RETURNS (boolean r | STATUS s) { // recoverable
IF .code != other.code THEN {
s = FAILURE("Money: cannot order different currencies")
RETURN (s)
}
r := .value < other.value
RETURN (r)
}
}
IF m1 < m2 THEN { ... } // PANICs on a currency mismatch
IF m1->isLessThan(m2) THEN { boolean r := $= } // handles it instead
ELSE { printlineErr($!) }
// `operator <` alone is enough — `>`, `<=` and `>=` are derived from it.
char c1 = 'a'
IF c1 < 'z' THEN { ... } // ERROR — comparison operator applied to a char
Rationale. Both routes keep every operator traceable to a greppable method rather than to opaque overload syntax, and a type that opted into neither gets a clear compile error (E2060) instead of a silent surprise. The direct route exists because an interface method cannot express a PARTIAL operator: Comparable.isLessThan returns a bare boolean, so it has no way to say "these two are not comparable" — and a pipe-XOR return, which does, cannot be the result of an operator sitting in an IF. Deriving the three remaining orderings from < keeps one method carrying the ordering logic, so a partial-order check cannot drift between four copies.
Part I.F.iii — Object construction
A class instance is built with CREATE; the concrete class name appears only when the compiler cannot infer it.
(a) Rule. A class instance is constructed with the CREATE keyword, which allocates the instance on the heap, stores an owning handle in the receiving binding, and calls the matching INIT. When the receiving binding's type is a concrete class, the class name is inferred and not repeated — Circle c := CREATE(5.0). When the receiving binding's type is an interface or an abstract class, the concrete class name is required after CREATE — Drawable d := CREATE Circle(5.0). The compiler cannot otherwise know which implementation to build.
Example.
Circle c := CREATE(5.0) // concrete type inferred — class name not repeated
Drawable d := CREATE Circle(5.0) // interface/abstract LHS — concrete class required
Rationale. When the binding's type already fixes the class, repeating it is noise. When the binding is an interface or abstract class, the compiler genuinely cannot pick an implementation, so the concrete name is required rather than guessed.
(b) Rule. When the receiving binding's type is a GROUP, CREATE is a compile error: a group has no fields or methods, so there is nothing to construct. A group binding is assigned a concrete value directly. CREATE appears only on the right-hand side of a typed declaration; passing a bare CREATE(...) as a call argument is a compile error, and the instance is given a name first.
Example.
Color col := CREATE(...) // ERROR — a GROUP has nothing to construct
draw(CREATE Circle(5.0)) // ERROR — bare CREATE as a call argument
Circle c := CREATE Circle(5.0) // name the instance first ...
draw(c) // ... then pass it
Rationale. A group carries neither fields nor methods, so there is nothing to allocate or initialize. Confining CREATE to a typed declaration means every constructed instance is named at birth, which keeps ownership visible rather than buried inside an argument list.
Part I.F.iv — Method calls
A method is called with -> on a receiver; the return shape determines how the call is consumed.
(a) Rule. A method is called with ->, on a receiver. Inside a class body, a bare method name followed by an argument list is an implicit self-call: validate(x) within a method of the class is exactly SELF->validate(x). Where a bare name could resolve either to a class method or to a module-level function, the method wins, and the function is reached by its module-qualified name. Cross-module calls use the same -> on a handle whose declared type carries a module qualifier; I.N gives the resolution rules.
Example.
METHOD process(int32 x) RETURNS STATUS {
validate(x) // implicit self-call — exactly SELF->validate(x)
...
}
person->getName() // explicit receiver
logger->write(msg) // cross-module call — resolution rules in I.N
Rationale. Inside a class the receiver is almost always self, so requiring SELF-> on every internal call would be noise. The method-wins rule means an internal call never silently binds to a same-named module function, and the module-qualified name is always available when the function is what was meant.
(b) Rule. A method's return shape determines how a call is consumed. A method returning a single value yields that value as an ordinary expression. A method returning several values with the comma shape, RETURNS (T a, STATUS s), populates every slot. The call is consumed by an assignment line that names a receiver for each. A method returning the pipe-XOR shape — RETURNS (T t | STATUS s) — populates exactly one slot, and the call is consumed by an IF or WHILE whose condition is the call; I.M describes that consumption form and the $= and $! tokens that read its slots.
Example.
int32 n = counter->value() // single value — ordinary expression
String data, STATUS s := readFile(path) // comma shape — every slot receives a name
IF q->dequeue() THEN { Token t := $RETURNED } // pipe-XOR — the call IS the condition
ELSE { printline($!) } // $= reads the value slot, $! the fail message
Rationale. Matching the consumption form to the return shape makes the shape self-documenting. A comma return populates every slot, so it is multi-assigned; a pipe-XOR return populates exactly one, so it is consumed as a condition that also selects the live slot.
(b.i) Rule. A return value is discarded deliberately and visibly with the marker $?. $? := call() drops a single return, and a $? slot drops one position of a multi-value return — String data, $? := readFile(path) keeps data and discards the STATUS. Because $? marks a value flowing out to the caller, it is valid only in return position. Writing it as a call argument (foo($?), E1136) or as a parameter name (METHOD foo(T $?), E1137) is a compile error: a don't-care is meaningful only for an output, never for an input the callee relies on.
Example.
$? := call() // drop a single return, visibly
String data, $? := readFile(path) // keep data, discard the STATUS slot
foo($?) // ERROR (E1136) — $? as a call argument
METHOD foo(T $?) // ERROR (E1137) — $? as a parameter name
Rationale. Discarding a result silently hides that a value was produced and ignored; the $? marker makes the discard visible. It is output-only because a don't-care is meaningful for a value flowing out, but an input the callee relies on can never be a don't-care.
(b.ii) Rule. (Requiring every non-void return to be consumed or explicitly $?-discarded is reserved and not yet enforced — a bare fire-and-forget call currently compiles.) ($_ is a separate token, reserved with no live form since the anonymous LOOP form was withdrawn in 2026-08-18 — see the Appendix special-token table.)
Rationale. The enforcement is specified ahead of implementation so the document is stable when the gap closes; $_ is called out here only to keep it from being confused with the $? discard marker — the two remain distinct even though $_ now has no form of its own.
Part I.F.v — Lambdas
Every lambda is announced by LAMBDA; its form follows from its body; a V1 lambda is non-escaping.
(a) Rule. Every lambda is announced by the LAMBDA keyword. There is no implicit-lambda form anywhere in the language: a bare brace block, or a parenthesized parameter list, is not treated as a lambda unless LAMBDA precedes it. The rule holds uniformly at every call site, assignment, and constructor argument.
Example.
LAMBDA x: x + 1 // a lambda — announced by LAMBDA
{ x + 1 } // NOT a lambda — a bare brace block is never a lambda
(x): x + 1 // NOT a lambda — a bare parameter list is never a lambda
Rationale. The keyword exists so that a reader never has to infer, from punctuation alone, that a fragment of code is a deferred computation rather than an immediate one.
(b) Rule. A lambda takes one of four forms, and the form follows from the body. The expression form is a single expression after a colon, and its value is the result — LAMBDA x: x + 1, or with several parameters LAMBDA acc, x: acc + x. Its parameter list may optionally be parenthesized, with no change of meaning. The block form is a brace-delimited sequence of statements with an explicit RETURN, and it requires its parameter list to be parenthesized so that the opening brace is unambiguously the body. The no-parameter form serves concurrent-task bodies and other zero-argument callbacks, written either as a bare LAMBDA: colon-block or as LAMBDA(). A lambda's parameter and return types are inferred from the signature it is passed to; where that inference is ambiguous, the lambda is assigned to a typed local first. A block-form lambda has exactly one RETURN.
Example.
LAMBDA x: x + 1 // expression form
LAMBDA acc, x: acc + x // expression form, several parameters
LAMBDA (x): x + 1 // expression form, parameters optionally parenthesized
LAMBDA (a, b) { RETURN (a + b) } // block form — parenthesized params, no colon before the brace
LAMBDA: { doWork() } // no-parameter form (colon-block)
LAMBDA() // no-parameter form (empty parens)
Rationale. Parenthesizing the block form's parameter list is what lets the opening brace be read unambiguously as the body rather than as part of the parameters. The expression form needs no such disambiguation, so its parentheses are optional.
(b.i) Rule. Capture is decided by context. Within a single task a lambda may capture any in-scope variable as a non-owning observation, the original owner remaining responsible for cleanup. A lambda used as the body of a CONCURRENT { … } task, or sent through a Channel, may not capture a caller-scoped variable non-owningly: it may reference only what it owns outright. I.L explains that restriction in full.
Rationale. Within one task the owner outlives the lambda, so a non-owning capture is safe. Across tasks the owner may not, so an escaping lambda must own what it references. The full concurrency rules are in I.L.
(c) Rule. A lambda is also a value with a type, and a method declares that it accepts one by naming LAMBDA as a parameter type — METHOD map(LAMBDA transform) RETURNS (…). A V1 lambda is non-escaping. It may be passed into a method and invoked there, but it may not be stored in a field, returned, or otherwise outlive the call that received it. The canonical use of the form is the higher-order collection surface. Array[T] exposes ->map(LAMBDA transform), ->filter(LAMBDA predicate), and ->reduce(seed, LAMBDA combine), each applying its lambda to the elements in turn (I.J.iii).
Example.
METHOD map(LAMBDA transform) RETURNS (Array[T]) // LAMBDA as a parameter type
Array[int32] doubled := nums->map(LAMBDA x: x * 2)
Array[int32] evens := nums->filter(LAMBDA x: x % 2 == 0)
int32 sum = nums->reduce(0, LAMBDA acc, x: acc + x)
Rationale. A non-escaping V1 lambda cannot outlive the call it was passed to, which keeps capture analysis local and lets the higher-order collection methods invoke it without any ownership handoff.
(c.i) Rule. Status: shipped in V1 (2026-06-26). A method may declare a LAMBDA parameter and invoke it in its body, and the higher-order collection surface built on that is live — Array[T]'s ->map, ->filter, and ->reduce are ordinary pure-Envzn loops over an invoked lambda. What remains deferred is the escaping lambda of (d): one stored in a field, returned, or otherwise outliving the call that received it. The shape specified here is the intended one, recorded ahead of the implementation per I.A; the document will not change when the gap closes, only the compiler will.
Rationale. Confining V1 to the non-escaping lambda is what keeps capture analysable: a lambda that cannot outlive its call site can borrow from the caller's scope without an ownership question, which is exactly the guarantee (e) relies on.
Part I.F.vi — Format strings
Positional composition with ->format(...) on a format-string literal; $1–$9 placeholders, argument auto-conversion, and per-placeholder format specs.
(a) Rule. Positional string composition is done with the ->format(...) method, called on a format-string literal. Inside a format string the placeholders $1 through $9 are active and are filled, in order, by the method's arguments; $$ produces a literal dollar sign. This is the only context in which $ carries meaning — in an ordinary string literal it is always a literal character. The + operator concatenates strings and is acceptable for the most trivial joins, but composition of more than two pieces is written with ->format().
Example.
String msg := ("Processing item $1 of $2")->format(current, total)
String greet := "Hello, " + name // acceptable — a trivial two-part join
String price := ("Cost: $$$1")->format(amount) // $$ → literal '$', then $1 fills
Rationale. ->format() is the idiomatic choice for composing text from parts: it allocates once rather than once per +, it keeps the template visually separate from the values, and it reads in a single scan. $ carries meaning only inside a format string, so an ordinary literal never needs escaping.
(b) Rule. Arguments are converted on the developer's behalf. A ->format(...) argument may be of any primitive numeric type, boolean, char, String, DynamicString, binary, ByteBuffer, DynamicByteBuffer, or any class instance. The compiler chooses the right render for each at the call site, so the developer never threads a value through an explicit (value INTO String) conversion first. A DynamicString is snapshotted to a String and a DynamicByteBuffer to a ByteBuffer before the runtime ever sees the value, so the developer does not pre-snapshot. A binary byte and a ByteBuffer render as space-separated uppercase hexadecimal by default — "DE AD BE" — under the rules of the binary column of I.D.vi. A class instance carries its own rendering through a METHOD toString() RETURNS String. One without a toString() renders as [??] and surfaces a compile-time warning (W10090); a class handle whose value is EMPTY renders as the literal "EMPTY". An opaque value with no recognizable carried type renders as "??".
Example.
String s := ("count=$1 ready=$2 tag=$3")->format(n, isReady, byteBuf)
// n rendered by its numeric type, isReady as boolean, byteBuf as "DE AD BE" (uppercase hex)
String t := ("point = $1")->format(p) // p renders via its METHOD toString() RETURNS String
String u := ("handle = $1")->format(maybeNull) // an EMPTY class handle renders as "EMPTY"
Rationale. Auto-conversion at the call site removes the boilerplate of a per-argument INTO String step and keeps the template readable. Snapshotting the dynamic containers up front means the runtime only ever sees an immutable value, so the render is stable.
(c) Rule. Each placeholder may carry a format spec — $N:<spec> — that adjusts how the value is rendered. The spec mirrors Python's format() mini-language: an optional <fill><align> pair, then an optional sign character, an optional minimum width, an optional grouping separator, and an optional type letter. The grouping separator is , or _, inserting a break every three digits for decimal and every four for the radix bases. The separators go into the digits and the width is then applied to the result, so they count toward the width and a zero-filled field carries them among its zeros; padding itself does not re-group. The integer-base type letters — :d, :x, :X, :o, :b — apply both to the integer-family arguments and to the binary containers (binary, ByteBuffer, DynamicByteBuffer). For an integer they choose decimal, lower-case hex, upper-case hex, octal, or binary digits. For a byte container they render every byte in that base, packed without the default inter-byte spaces. :s is the universal "default render" letter and accepts every argument shape. Width is a minimum, not a cap, for numeric renders; a String whose rendered length exceeds the requested width is truncated with ... when width ≥ 6. The grammar of the spec is given in (d), the type letters in (e), the width and sign rules in (f), and truncation and the special renders in (g). A type letter incompatible with its argument's type — :x on a float, say — is diagnosed E8020 by the compile-time linter the compiler runs over every literal template.
Example.
int32 current = 3
int32 total = 10
String msg := ("Processing item $1 of $2")->format(current, total)
String greet := "Hello, " + name // acceptable — a trivial two-part join
String report := ("Error at $1:$2 — $3")->format(filename, lineNum, errorMsg)
int32 v = 255
float64 pi = 3.14
String hex := ("color = #$1:0>6X")->format(v) // "color = #0000FF"
String padded := ("[$1:>8] = [$2:<+8]")->format("pi", pi) // "[ pi] = [+3.14 ]"
Rationale. Mirroring Python's mini-language means the spec grammar is already familiar to most readers, so the one piece of punctuation-dense syntax in the language is the piece a developer is most likely to already know.
(d) Rule. The spec is parsed by position, each element optional:
$<N>[:<spec>]
<spec> := [<fill> <align>] [<align>] [<sign>] [<width>] [<grouping>] [<type>]
<fill> := any single Unicode code point (default: U+0020 space)
<align> := '<' | '>' | '^' // left / right / center
<sign> := '+' | '-' // default '-' — show only negatives
<width> := one or more decimal digits
<grouping> := ',' | '_' // separator every 3 digits decimal, 4 for x/X/o/b
<type> := 'd' | 'x' | 'X' | 'o' | 'b' | 's'
A <fill> is recognized by lookahead: if the spec's second character is one of <, >, ^, the first character is the fill. Otherwise the spec begins at the optional <align>. Grouping is applied to the digits before the width, so the separators count toward the width; the padding that follows does not re-group, which makes 1234 at 0>9,d render 00001,234. V1 fixes the group at three digits for decimal and four for the radix bases.
Example.
"$1:5d" // no fill, no align, width 5, decimal
"$1:>5d" // no fill, right align, width 5, decimal
"$1:0>5d" // fill '0', right align, width 5, decimal
Rationale. One character of lookahead is all the fill rule needs, because an alignment character can never itself be a fill: a spec that means to pad with > writes it as the fill and the alignment after it. Fixing the grouping sizes rather than making them configurable keeps the spec a fixed grammar a reader can learn once.
(e) Rule. Six type letters are defined.
| Letter | Applies to | Effect |
|---|---|---|
d |
int family, char, binary, ByteBuffer, DynamicByteBuffer |
decimal — for char the code point; for a byte container each byte in decimal, space-separated |
x / X |
int family, binary, ByteBuffer, DynamicByteBuffer |
hexadecimal, lower / upper case |
o |
int family, binary, ByteBuffer, DynamicByteBuffer |
octal |
b |
int family, binary, ByteBuffer, DynamicByteBuffer |
binary digits |
s |
any | generic — default-render, then apply width, align, and truncation |
| (none) | any | default render per (b), then width and align, without truncation |
Applied to a byte container, a type letter renders without inter-byte separators — :x over the bytes [0xDE, 0xAD, 0xBE] yields "DEADBE", where the default render yields "DE AD BE".
Rationale. :s exists so a developer who wants only the width and alignment tools need not know which default render applies to the argument's type.
(f) Rule. <width> is a minimum rendered length, counted in Unicode code points rather than bytes; a shorter render is padded to width with <fill>, and a width of 0 behaves as no width at all. Default alignment follows the argument's category. Numbers align right: the integer and float families, binary, ByteBuffer, DynamicByteBuffer. Everything else aligns left: String, boolean, char, opaque, and a class rendered through toString(). + forces a sign character on positive values, - is the default and shows a sign only on negatives, and the sign applies to int8…int64 and float32/float64. On the unsigned family (uint8…uint64) a + spec is a no-op, as it is on binary, which carries no sign at all.
Rationale. Right-aligning numbers and left-aligning text is what makes a column of output line up the way a reader expects, so the default matches the common case and the explicit alignment characters cover the rest. A + on an unsigned value is a no-op rather than an error because the value is never negative, and printing a sign there would mislead.
(g) Rule. When a <width> is given and the rendered value is longer than it, the output is truncated. At width 6 or greater it is the first <width> characters followed by ..., a final length of <width> + 3. Below width 6 it is the first <width> characters with no ellipsis. Truncation is defined for every argument type, though for numeric renders the case is rare. Three renders are produced before any width or alignment applies: a class-typed argument that is EMPTY renders the literal "EMPTY", an opaque value with no recognizable carried type renders "??", and a class without a toString() renders "[??]".
Rationale. Below width 6 an ellipsis would consume half the field or more, so the truncation drops it rather than spend the space saying that space ran out. The three placeholder renders are produced first so that width and alignment treat them like any other text — an EMPTY handle in a right-aligned column lines up with the values around it.
Example.
("[$1:>10]")->format(myEmptyHandle) // "[ EMPTY]" — EMPTY rendered, then right-aligned
("$1:10")->format("HelloWorldGoodbye") // "HelloWorld..." — width 10, so ellipsis is added
("$1:4")->format("HelloWorldGoodbye") // "Hell" — width below 6, no ellipsis
Section I.G — Statements and Assignment
Statements, executed for their effect rather than a value. Centred on the three assignment operators — = copy, := move, =@ reference — and the binding shape each one may introduce.
(a) Rule. A statement is executed for its effect rather than for a value it produces. Statements end at a newline (as I.C establishes) and run in the order written. The principal statement is assignment.
Example.
count = 0 // executed for effect, ends at newline
System->exit(0) // a statement; the value, if any, is discarded
Rationale. Distinguishing statements from expressions keeps the reading model simple: an expression names a value, a statement makes something happen. Assignment repays careful reading most, because the operator chosen is itself part of the statement's meaning.
(b) Rule. Envzn has three assignment operators, and each has exactly one meaning: = binds a value, := binds ownership, and =@ binds a reference. The operator is not shorthand for something the left-hand side already implies; it is itself part of the statement's identity, and changing it is a visible semantic change. The coalescing read ?? in the fourth row is an expression, not an assignment operator. It is defined in I.D.iv, and appears here only so the four may be seen together.
| Operator | Meaning |
|---|---|
= |
Value assignment. Bind the left-hand side to a value — never to ownership of an object. For a primitive, a value-class, a STATUS, or an enum this is a bit copy of any compatible expression. For a String the sole permitted right-hand side is a string literal (String is the one object with a literal form, and it is never EMPTY). For any other class type the sole permitted right-hand side is EMPTY, which holds no object and so transfers no ownership. |
:= |
Ownership. Bind the left-hand side as the new owner of an object, by constructing it (CREATE), copying it (->clone()), or moving it from another owner. A move leaves the source unusable: a moved-from field becomes EMPTY, a moved-from local or parameter is poisoned, and any later read of it is a compile error. No clone is ever inserted implicitly. |
=@ |
Reference. Establish a non-owning reference to a place expression. The binding is subject to the reference checker's lifetime and aliasing rules, given in I.I. |
?? |
Coalescing read (an expression, not an assignment). Yields the left operand if it is present, otherwise the right; see I.D.iv. |
Example.
int32 count = 0 // = value: bit copy of a primitive
String name = "Ada" // = value: String's sole RHS is a literal
Widget w = EMPTY // = value: the only class RHS under = is EMPTY
Widget w2 := CREATE Widget(5) // := ownership: construct a new owner
Widget w3 := other->clone() // := ownership: copy is an owning bind
REFERENCE Widget r =@ w2 // =@ reference: non-owning view of a place
int32 chosen = a ?? b // ?? is a read (expression), not an assignment
Rationale. One operator, one meaning, keeps the cost of an object visible in the source that causes it. A reader never has to infer from the left-hand side's type whether a line copies a value, transfers ownership, or takes a borrow. Listing ?? alongside the three makes the family legible at a glance, while its italic label marks it as the odd one out.
(c) Rule. The operator and the shape of the left-hand side must agree, and the agreement is enforced. =@ requires a REFERENCE or MUTABLE REFERENCE left-hand side; using it with an owning left-hand side is a compile error. = and := require an owning left-hand side; using either with a reference left-hand side is a compile error.
Example.
REFERENCE Widget r =@ w // =@ needs a reference LHS — ok
Widget owned =@ w // ERROR — =@ with an owning LHS
Widget owned := CREATE Widget(1) // := needs an owning LHS — ok
REFERENCE Widget r2 := CREATE Widget(1) // ERROR — := with a reference LHS
Rationale. The left-hand side's shape and the operator declare the same fact: whether this binding owns or observes. Requiring them to agree makes a mismatch a caught error rather than a silent contradiction the reader must reconcile.
(c.i) Rule. The right-hand side shapes follow from the operator's meaning. A = takes a value, never ownership of an object. That is a literal or computed expression for a primitive, value-class, STATUS, or enum; a string literal for a String; and EMPTY for any other class type. Every other class right-hand side under = is a compile error: a CREATE, a ->clone(), a method result, or a bare variable-to-variable copy. Constructing, cloning, copying, or moving an object is ownership, and the language refuses to launder it through the value operator. String reports this as E2020, every other class as E3023.
Example.
int32 n = a + b // = value: a computed primitive expression
String greeting = "hello" // = value: a String literal
Widget w = EMPTY // = value: EMPTY for any other class
String s = other->clone() // ERROR E2020 — clone is ownership, not a value
Widget w2 = CREATE Widget(1) // ERROR E3023 — CREATE is ownership under =
Widget w3 = existing // ERROR E3023 — variable-to-variable copy is a move
Rationale. Ownership operations look like values only if you let them. Forcing a CREATE, ->clone(), or copy to announce itself with := keeps every allocation and every ownership transfer visible at the line that causes it, and E2020/E3023 name the laundering attempt precisely.
(c.ii) Rule. A := takes exactly the ownership-bearing right-hand sides: a CREATE, a ->clone(), a method-call result, or an owning place expression. Each constructs or moves ownership. A =@ takes a place expression only: a variable, a field, an indexed access, or a method result that itself returns a reference. It never takes a literal, an arithmetic expression, or a method result that returns an owned value.
Example.
Widget w := CREATE Widget(5) // := construct ownership
Widget c := source->clone() // := copy is an owning bind
Widget m := factory->make() // := method result carrying ownership
Widget moved := oldOwner // := move from an owning place expression
REFERENCE Widget r =@ w // =@ a variable — a place expression
REFERENCE Widget f =@ w.child // =@ a field — a place expression
REFERENCE Widget e =@ arr[i] // =@ an indexed access — a place expression
REFERENCE Widget bad =@ CREATE Widget(1) // ERROR — =@ with a constructed value, not a place
Rationale. Each operator accepts only the right-hand-side shapes its meaning supports, so the operator alone tells the reader what class of thing follows: := an owning source, =@ a durable place to borrow from — never a temporary value that would dangle.
(d) Rule. A move from an owning place expression is where ownership visibly changes hands, so its outcome is stated precisely. When the right-hand side of a := is a field that owns its target, the move transfers ownership to the left-hand side and leaves the source field EMPTY. When it is a local or a parameter, the move leaves the source poisoned: the binding still exists, but any subsequent read of it is a compile error. A := whose right-hand side is a non-owning reference is itself a compile error. A reference does not own what it observes, so there is nothing for it to transfer. To take a copy from a reference, the developer writes ->clone() explicitly, and the class must implement Cloneable; the language never inserts a clone on the developer's behalf.
Example.
Widget taken := .child // move from an owning field
// → .child becomes EMPTY after this line
Widget moved := local // move from a local
printline(local->name()) // ERROR — local is poisoned; read after move
REFERENCE Widget r =@ source
Widget bad := r // ERROR — := from a non-owning reference
Widget copy := r->clone() // ok — explicit clone (class must be Cloneable)
Rationale. A move must leave no usable second handle to a single owner, so the source is neutralized. A field becomes EMPTY, since it may be re-used; a local or parameter is poisoned, since its lifetime is ending anyway. Refusing := from a reference, and never inserting a clone silently, upholds the principle that an allocation should be visible in the source that causes it. If a copy happens, ->clone() says so.
(e) Rule. A bare pair of braces inside a method body is an anonymous block — a scoping statement with no keyword and no name. Variables declared inside it go out of scope, and owned values are freed, at the closing brace. A RETURN inside an anonymous block returns from the enclosing method, not merely from the block; BREAK and CONTINUE still apply to the nearest enclosing loop. Anonymous blocks nest up to fifteen levels deep. An empty one is legal but draws a compiler warning, since an empty block is more often unfinished code than deliberate.
Example.
METHOD process() RETURNS STATUS {
{ // anonymous block — bounds a lifetime
Buffer scratch := CREATE Buffer(1024)
scratch->fill()
RETURN (SUCCESS) // returns from process(), not just the block
} // scratch freed here if we had not returned
{ } // WARNING — empty anonymous block
}
Rationale. The anonymous block is two tools at once. It bounds the lifetime of an owned value, or an iterator's mutation lock, precisely; and it groups the statements of one phase of a method under a visible boundary. Keeping RETURN, BREAK, and CONTINUE bound to the method and loop (not the block) means adding braces for scoping never silently changes control flow. The empty-block warning catches the common case where the braces mark work not yet written.
Section I.H — Control Flow
Branching and looping built to stay locally legible: the conditional headers, the conditional, the loops, FINALLY, MATCH, and conditional compilation.
(a) Rule. Control flow in Envzn is built to be locally legible. A reader can see where a condition ends and a body begins without holding the operator-precedence table in mind, and is nudged toward polymorphism when a tree of conditionals has grown deep enough to be hard to follow. The constructs below — the conditional, the loop family, the guaranteed-cleanup block, and the destructuring MATCH — all serve that goal.
Example.
IF x > 5 THEN { ... } // condition ends at THEN, body begins at {
MATCH shape { // destructuring — each arm BINDS
WHEN Circle c: { ... }
WHEN Square s: { ... }
}
Rationale. Local legibility is the through-line for the whole section. Every rule that follows spends exactly as much syntax as it takes to keep a header or a branch unambiguous to a reader scanning top-to-bottom, and no more.
Part I.H.i — Conditional headers
(a) Rule. Every conditional keyword takes a condition between the keyword and the opening brace, and pairs it with a marker word that signals end of condition, start of body: IF and ELSE IF pair with THEN; WHILE and UNTIL pair with DO. The post-condition loops DO { … } WHILE and DO { … } UNTIL take no marker.
Example.
IF x > 5 THEN { ... }
WHILE i < n DO { ... }
DO { ... } WHILE (more) // the trailing keyword is its own marker
Rationale. The marker exists so that the parser, and the reader, can always find where the condition ends. Neither has to scan ahead to the brace or hold an operator-precedence table in working memory. The post-condition loops need no marker because their trailing keyword already is one.
(b) Rule. A condition is either simple or compound. A simple condition is a single boolean expression with no top-level boolean operator. It must be written either parenthesized, in which case the marker is optional, or with the marker word, in which case the parentheses are omitted. A bare simple condition with neither is a compile error. A compound condition joins two or more sub-expressions with a boolean operator; each sub-expression must be parenthesized, while the outer parentheses around the whole and the marker are both optional.
Example.
IF (x > 5) { ... } // simple, parenthesized — marker optional
IF x > 5 THEN { ... } // simple, marker form — parentheses omitted
IF x > 5 { ... } // ERROR — neither parentheses nor marker
IF (x > 5) AND (y < 0) { ... } // compound — each clause parenthesized
Rationale. Requiring one of the two disambiguators on a simple condition guarantees a unique end-of-condition token in every case. On a compound condition the per-clause parentheses already supply that boundary, so the marker and the outer parentheses become redundant and are optional rather than required. The rule asks for exactly as much punctuation as it takes to keep the header unambiguous.
Part I.H.ii — The conditional
(a) Rule. IF, with optional ELSE IF and ELSE branches, is the ordinary conditional. Nesting is permitted, but the compiler issues a warning past three nested IF statements.
Example.
IF (score >= 90) { grade = "A" }
ELSE IF (score >= 80) { grade = "B" }
ELSE { grade = "C" }
IF (a) { // depth 1
IF (b) { // depth 2
IF (c) { // depth 3
IF (d) { ... } // WARNING — past three nested IFs; likely a MATCH
}
}
}
Rationale. A conditional tree that deep is usually a MATCH that has not yet been recognized as one. The compiler flags it toward the clearer construct rather than forbidding it outright.
Part I.H.iii — Loops
(a) Rule. Collection iteration is written with FOR … IN. (FOREACH was specified here as an identical-semantics synonym; it was withdrawn 2026-08-18 — a bare synonym is the plainest violation of one way to do things, and it never lowered. The keyword stays reserved (E1163) so no program can bind it and so a future strided form may adopt it: DESIGN_QUEUE #68, V3.) Iterating a collection this way binds the loop variable as a non-owning handle into the collection, and locks the collection against mutation for the body of the loop.
Example.
FOR item IN items { process(item) }
FOR order IN orders { process(order) }
Rationale. Envzn offers several loop forms because the cases genuinely differ, and the guiding rule is to use the form whose surface most nearly matches the intent. The iterator model and the mutation lock that underpin collection iteration are described with the aggregates in I.J.
(b) Rule. Counted iteration has two forms. The range form — FOR i = 0 TO 10, optionally with STEP — is preferred for a simple constant range; TO INCLUDES its bound; UNTIL STOPS BEFORE it. FOR i = 1 TO 6 runs six times and ends with i == 6; FOR i = 1 UNTIL 6 runs five times and ends with i == 5. The bound of an UNTIL is a value the loop never takes. This is the same pairing Kotlin and Scala use for the same two words, and it is what makes FOR i = 0 UNTIL a.length walk an array exactly — the dominant idiom, and the reason the exclusive form carries the shorter, commoner spelling. STEP defaults to +1 and accepts a negative value for a countdown. The C-style form, FOR int i = 0; i < total; i = i + 1, is for cases where the condition or the step is more complex than a constant range allows.
Example.
FOR i = 1 TO 6 { print(i) } // 1 2 3 4 5 6 — six times, INCLUDES 6
FOR i = 1 UNTIL 6 { print(i) } // 1 2 3 4 5 — five times, STOPS BEFORE 6
FOR i = 0 UNTIL a.length { print(a[i]) } // walks the array exactly; a.length is never an index
FOR i = 0 TO a.length { print(a[i]) } // WRONG — runs one past the end
FOR i = 0 TO 10 STEP 2 { print(i) } // 0 2 4 6 8 10 — STEP, still inclusive of 10
FOR i = 10 TO 0 STEP -1 { print(i) } // negative STEP counts down
FOR int i = 0; i < total; i = i + 1 { ... } // C-style form — the only place a bound is written out
Rationale. The range form covers the common case in the fewest tokens; the C-style form is the escape hatch for a header a constant range cannot express.
(b.i) Rule. The C-style header carries the only semicolons in the language. Its three clauses and two semicolons must occupy the single line of the FOR keyword; splitting the header across lines is a parse error.
Example.
FOR int i = 0; i < total; i = i + 1 { ... } // three clauses, two semicolons, one line
FOR int i = 0; // ERROR — header split across lines
i < total;
i = i + 1 { ... }
Rationale. Keeping the three-clause header on one line means the reader never hunts across lines for a stray semicolon, and confines the language's only semicolons to a single, recognizable place.
(c) Rule. When no loop variable is needed at all, REPEAT n { … } runs its body a fixed number of times, and REPEAT { … } with no count runs it indefinitely — the bare infinite loop, left only by BREAK, RETURN, or PANIC. Dropping the count reads as "repeat indefinitely", so no separate keyword is spent on it, and writing a condition that is constantly true (WHILE TRUE) is a style warning that points here instead. The pre-condition loops WHILE and UNTIL test before each iteration, UNTIL being a readable spelling of WHILE NOT. The post-condition loops DO { … } WHILE and DO { … } UNTIL test after, and so always run at least once.
Example.
REPEAT 3 { printline("tick") } // body runs exactly 3 times, no loop variable
REPEAT { IF done() THEN { BREAK } } // no count — runs until something leaves it
WHILE i < n DO { i = i + 1 } // tests before — may run zero times
UNTIL done DO { step() } // UNTIL c ≡ WHILE NOT c
DO { attempt() } WHILE (retries > 0) // tests after — always runs at least once
Rationale. Each form names its intent on the surface: a fixed count, a guard checked first, or a body that must run once before its guard is checked.
(d) Rule. The LOOP keyword has exactly one form: collection iteration that carries the element's position alongside the element. It is written LOOP name IN collection WITH INDEX index { … }, and both clauses are mandatory — a LOOP without WITH INDEX is a compile error, and so is WITH INDEX on any other loop keyword. name binds the element exactly as FOR … IN does, as a non-owning REFERENCE, and the collection is locked against mutation for the body. index binds the element's zero-based position as an int64 — the width of every count and extent in the language (2026-09-08; it was int32 before). It is read-only: assigning to it is a compile error, because it is the loop's own state and a writable copy reintroduces the off-by-one this form exists to remove. Both names are scoped strictly to the loop block.
Example.
LOOP row IN orders WITH INDEX i {
IF i > 100 THEN { BREAK } // i — zero-based int32 position
process(row) // row — REFERENCE to the element
}
Rationale. Reading an element together with its position is the most common iteration need after plain traversal, and every language Envzn is measured against provides it. Without it the developer must hand-roll a counter beside the loop — the classic off-by-one site, and one the compiler cannot check. LOOP is the construct that makes the position a binding rather than a bookkeeping chore. This is why the choice between FOR … IN and LOOP is functional and never stylistic: if the position is needed, LOOP is the only construct that has it; if it is not, FOR … IN is the only construct that should be reached for.
(d.i) Rule. LOOP accepts exactly what FOR … IN accepts. Over an ordered collection the index is the element's position; over an unordered one it is the position in that traversal, which is well defined for the traversal but not stable across runs or implementations — a developer choosing to index an unordered collection has accepted that, and the compiler does not warn. The index always starts at 0 and increases by one per iteration, including when the body executes CONTINUE; BREAK ends the loop with the index at the element that triggered it.
Example.
LOOP name IN roster WITH INDEX i {
printline(("$1. $2")->format((i INTO String), name))
}
LOOP entry IN scores WITH INDEX position {
IF position == 0 THEN { CONTINUE } // skip the first; position still advances
tally(entry)
}
Rationale. Constraining LOOP to what FOR … IN already iterates keeps one iteration model rather than two. Declining to warn on an unordered collection matches the language's general posture: it reports what it can prove, and traversal order is the developer's to reason about.
(d.ii) Rule. LOOP is not a counter loop, and writing it as one is a compile error; counter loops use FOR. LOOP carries no iterator handle: a loop needing peek-ahead, conditional advancement, or explicit cursor control uses a kernel iterator directly, consumed through its pipe-XOR navigation in a WHILE (I.J).
Example.
LOOP i = 0 TO 10 { ... } // ERROR — LOOP is not a counter loop; use FOR
LOOP row IN orders { ... } // ERROR — LOOP requires WITH INDEX; use FOR … IN
// explicit cursor control — a kernel iterator, not LOOP
ReferenceIterator OF Order it := orders->iterator()
WHILE it->next() DO {
REFERENCE Order row =@ $RETURNED
process(row)
}
Rationale. Steering counter loops to FOR keeps each keyword to the one job its surface advertises. The iterator surface is already a first-class, fully expressive part of the language — next(), peek(), and skip() return the pipe-XOR shape that a WHILE consumes directly — so wrapping it in a second syntax would add a way to do something the language already does one way. (Earlier drafts specified a LOOP iterator-handle form exposing .item / .index / .hasNext as fields, and an anonymous LOOP $_ IN form. Both are withdrawn: the handle contradicted I.A.i(c.i)'s prohibition on phantom computed fields and named a kernel type, Iterator[T], whose surface is methods and carries no position; the $_ form never tokenized.)
(e) Rule. BREAK exits the innermost enclosing loop immediately and CONTINUE skips to its next iteration; both are valid in every loop form. A block or loop may additionally be given a name with the LABEL prefix — LABEL <name> <statement> — and BREAK <name> then exits that named block, while CONTINUE <name> begins the next iteration of that named loop. LABEL prefixes any loop form or a bare block: LOOP, REPEAT (counted or bare), all three FOR forms, WHILE, UNTIL, DO-WHILE, and { … }. Both jumps are lexical and structured: BREAK transfers only forward, to immediately after the named block's closing brace; CONTINUE transfers only backward, to the top of the named loop. The named label must lexically enclose the jump, control may never enter a block or move sideways, and no jump may leave the enclosing method. CONTINUE <name> requires the name to belong to a loop. A label's extent is the block it prefixes, but its NAME is unique within the class: the same name may not be used for two labels anywhere in one class file, even in different methods, and may not collide with an in-scope variable or parameter. Every scope the jump leaves is unwound exactly as an ordinary scope exit: each intervening local is destroyed in reverse declaration order, every CLEANUP runs, and a TRY being left runs its FINALLY first. BREAK <name> and CONTINUE <name> may not appear inside a FINALLY, and may not name a label outside an enclosing PARALLEL, CONCURRENT, task body, or LAMBDA.
Example.
FOR item IN items {
IF (item->isSentinel()) { BREAK } // exits this loop
IF (item->isSkippable()) { CONTINUE } // next iteration
process(item)
}
LABEL scan LOOP row IN grid WITH INDEX r {
FOR cell IN row {
IF cell->isBad() THEN { BREAK scan } // exits the LOOP
IF cell->isSkip() THEN { CONTINUE scan } // next row
}
}
LABEL attempt { // a named block, not a loop
IF NOT connect() THEN { BREAK attempt }
IF NOT auth() THEN { BREAK attempt }
send()
} // BREAK attempt lands here
LABEL outer FOR a IN xs {
PARALLEL { FOR b IN ys { BREAK outer } } // ERROR — crosses a PARALLEL boundary
}
Rationale. Exiting more than one level of nesting is a real need, and the alternative Envzn offered until now — a boolean threaded through the inner exit, the outer guard, and a post-loop test — smears one intent across three places the reader must reassemble. That is the opposite of control flow being visible on the page. LABEL is deliberately not a goto: both jump targets are block boundaries determined lexically, the compiler proves the target encloses the jump, and control can never enter a block or move sideways. Requiring the name to be unique class-wide, though the label's extent is only its own block, is a readability rule rather than a scoping necessity — searching a class for a label finds exactly one declaration, and no reader has to work out which one a jump means. This is the same capability Java, Rust, Swift, Go, Kotlin, Zig, and JavaScript provide, obtained without adding an unstructured jump to the language.
(e.i) Rule. For a single-statement body, the postfix shorthands read like English.
Example.
print(item) FOR item IN items // postfix FOR
x = x / 2 WHILE x > 1 // postfix WHILE
Rationale. When the body is a single statement, the postfix form puts the action first and the iteration second, reading as a sentence rather than a block.
(f) Rule. HOTLOOP is a modifier written immediately before a counted FOR (or a WHILE). It asks the compiler to hoist the loop body's per-element array bounds checks into a single up-front range check per array, so the body's indexed reads run without a per-iteration bounds branch. It is not a request to skip checks — the memory-safety floor is never traded. The compiler proves every indexed access covered by the one hoisted check; a HOTLOOP it cannot prove is a compile error (E5027), not a silently-unchecked loop. A HOTLOOP is provable only when four conditions hold together. The loop is a simple counted range, FOR i = 0 UNTIL n. The loop variable is not reassigned. The bound n is loop-invariant, since the C++ condition re-reads it each iteration. And every array subscript is an affine index in the loop variable — arr[i], or arr[c + i] with a loop-invariant c — over an array that is read-only in the body. Any write, reassignment, .field or method use, or call-argument use of the array could change its length and stale the hoisted check.
Example.
// BENEFICIAL — an offset slice: the bound `len` is NOT the indexed array's own
// length, so the compiler cannot correlate them and the ordinary loop keeps a
// per-element check. HOTLOOP hoists it to one `off + len <= key.length` check.
HOTLOOP FOR i = 0 UNTIL len {
h = (h BXOR (key[off + i] INTO uint64)) &* PRIME
}
// NO-OP — the bound IS the array's length, so the ordinary compiler already
// proves `arr[i]` in range and elides the check; HOTLOOP changes nothing here.
HOTLOOP FOR i = 0 UNTIL arr.length { sum = sum + arr[i] }
// ERROR E5027 — `buf` is mutated in the body, so the hoisted check would be stale.
// HOTLOOP FOR i = 0 UNTIL n { buf->clear() total = total + buf[i] }
Rationale. HOTLOOP earns its keep in exactly one shape: a loop whose bound is not the indexed array's own .length — an offset slice, a caller-supplied length, several arrays of differing lengths. There the ordinary optimizer cannot prove the relationship, so it keeps a bounds branch on every element. When the bound is the array's length the optimizer already elides the check, and HOTLOOP is a harmless no-op. A developer reaches for it only after the emitted code shows a per-element check that structure alone will not remove. Because the compiler proves the hoist rather than trusting the developer, a HOTLOOP can never read out of bounds. An out-of-range run panics with IndexOutOfBoundsError from the up-front check, before any element is touched. This is the I.A tie-break — the fast machinery (the proof and the hoisted check) lives in the compiler; the developer writes one readable keyword and the floor is kept. The proof and its E5027 reject live in analyze/hotloop_diagnostics.py; the hoisted check and the unchecked reads it authorizes are the value-array .data()[i] lowering of I.J.i.
Part I.H.iv — FINALLY
(a) Rule. FINALLY is the optional trailing clause of a TRY (I.M.ii) — it is not a standalone method-level construct. When present it runs last on every exit path of its TRY: whether the TRY/RECOVER completes normally, a RECOVER handles a PANIC, or a PANIC unwinds through it.
Example.
TRY {
parse(handle)
}
FINALLY {
handle->close() // runs on both the normal and the unwinding exit
}
Rationale. Attaching cleanup to the TRY whose scope it guards keeps the intent local to the code that can fail. It also guarantees the cleanup runs on every way out of that scope.
(b) Rule. A TRY carries at most one FINALLY; placing a RETURN or PANIC inside it is a compile error.
Example.
TRY { work() } FINALLY { cleanup() } FINALLY { more() } // ERROR — a second FINALLY on one TRY
TRY { work() } FINALLY { RETURN (0) } // ERROR — RETURN inside FINALLY
Rationale. One cleanup block per TRY, with no exit of its own, keeps the guarantee simple to reason about. There is exactly one place cleanup lives for that scope, and it can never itself divert control.
Part I.H.v — MATCH
(a) Rule. MATCH is the language's destructuring branch: exhaustive dispatch over a closed set of alternatives, binding whatever the alternative carries. Envzn has three closed sets, and MATCH is how each is taken apart. A GROUP (I.J.v) is a closed set of types — each WHEN names a member and binds the subject narrowed to it. A dev-defined ENUM (I.K.viii) is a closed set of cases — each WHEN names one case; a V1 enum case is a bare name and carries no payload, so the arm selects without binding. A STATUS (I.M.i) is a closed set of kinds — FAILURE / PARTIAL_SUCCESS bind the message and optional code. STATUS is not a special construct here: it is the built-in instance of the enum form, and it is the instance that carries a payload — a dev enum has no way to declare one in V1. There is no fallthrough between WHEN blocks.
MATCH is not a multi-way branch on open-ended values. A closed set is one the compiler can enumerate — a group's members, an enum's cases, a status's kinds. An int32, a char, or a String is not such a set, so MATCH does not accept literal, list, or range patterns, implicit-subject comparisons, IF guards, a subjectless condition role, or a DEFAULT arm. Dispatch on an open-ended value is written IF / ELSE IF; dispatch on behaviour is written with polymorphism. (Removed 2026-08-02 — see (a.i).)
Example.
MATCH shape { // GROUP — a closed set of TYPES
WHEN Circle c: { printline(c->radius() INTO String) }
WHEN Square s: { printline(s->side() INTO String) }
}
MATCH direction { // ENUM — a closed set of CASES
WHEN NORTH: { ... } // bare case — selects, binds nothing
WHEN SOUTH: { ... }
}
MATCH status { // STATUS — the built-in enum
WHEN SUCCESS: { printline("ok") }
WHEN FAILURE(msg): { printline(msg) }
WHEN PARTIAL_SUCCESS(msg): { printline(msg) }
}
Rationale. What the surviving roles share is a set the compiler can enumerate, which is the only setting in which exhaustiveness means anything — and two of the three do something IF cannot: they bind a narrowed instance or a status message that is otherwise unreachable. The bare enum case binds nothing, and is kept for the other half of the justification: naming every case of a closed set is checkable, where an IF chain over the same cases is not. Value dispatch has neither property — an open-ended value can be neither enumerated nor destructured — so it earned no keyword of its own; it is an IF chain wearing different punctuation, and a long one is the signal to reach for polymorphism instead. Forbidding fallthrough removes the classic footgun of a forgotten break carrying control into the next branch.
(a.i) Rule (2026-08-02 — LOCKED). The value role of MATCH is removed from the language: value and list patterns, the inclusive TO range pattern, the implicit-subject comparison (WHEN == x:, WHEN .method():), the IF guard on a WHEN, the subjectless condition role (MATCH { WHEN <bool>: }), the DEFAULT arm, and the WHEN EMPTY arm. Matching on a dev-defined ENUM is retained — an enum is a closed set and its MATCH is the general form of which STATUS is the built-in instance. Each removed form has an exact IF / ELSE IF replacement with identical lowering, and absence is handled by IS VALID / ?? (I.D.iv) with dereference safety enforced at the point of use by E2080, not by the shape of a branch. A pattern that is not a member of a closed set — a literal, a range, an implicit-subject comparison, a guard, a DEFAULT, or a subjectless MATCH — is E4030 (control:match-not-closed-set), which names the IF / ELSE IF replacement. The diagnostics that policed the removed forms retire with them, and are named here by slug rather than number because a retired code must not be cited as live: control:optional-match-incomplete, style:match-single-arm, and style:empty-when-none. E4013 (duplicate WHEN) does not retire — its fire sites police the type-narrowing and enum forms, both retained, so a repeated WHEN NORTH: is still unreachable code.
Rationale. One keyword had accreted six syntactic forms across two AST node shapes, with three different names for the same else-arm and two different places a binder could live. That inconsistency — not the concept of matching — was the defect source: four independent compiler bugs traced to it, including passes that silently skipped every MATCH body. Measured against the whole corpus the removed forms had one production use in 211 shipping source files, against 2,104 IF statements, and every form lowered to the same if/else if chain, so nothing was bought at the cost. What remains is the part that carries meaning: destructuring that binds.
(b) Rule. Every WHEN arm names one alternative of the subject's closed set and binds what that alternative carries, for that arm only. A MATCH whose arms do not cover every alternative — an uncovered GROUP member, ENUM case, or STATUS kind — is a warning: for a GROUP the alternatives are its written members, which is also its transitive set, because only a group whose members all have one concrete representation can type a value at all (I.J.v(a.i)) — the set is closed, so a gap is usually an oversight, but leaving one deliberately unhandled is a legitimate choice the compiler flags rather than refuses. A possibly-EMPTY subject is not matched at all — absence is IS VALID / ?? per I.D.iv, and dereferencing an unproven value is E2080 wherever it occurs.
Example.
MATCH shape { // GROUP Shape { Circle, Square, Triangle }
WHEN Circle c: { ... }
WHEN Square s: { ... }
} // WARNING — Triangle uncovered
MATCH direction { // ENUM Direction { NORTH, SOUTH, EAST, WEST }
WHEN NORTH: { ... }
WHEN SOUTH: { ... }
} // WARNING — EAST, WEST uncovered
Rationale. The binding arm is the reason the construct exists: it yields a value — narrowed instance, associated payload, status message — that the analyzer can then trust. Coverage over any closed set is checkable, so the compiler reports the gap; it warns rather than errors because deliberate partial handling is a real pattern and the fallthrough is silent, not unsafe.
(b.i) Rule. A MATCH is a statement and does not itself yield a value; its arms read as ordinary bodies. A WHEN carries no guard — a runtime test that further refines an arm is an ordinary IF inside that arm's body.
Example.
MATCH request {
WHEN Order o: {
IF o->isPaid() THEN { ship(o) } ELSE { hold(o) } // the guard, written plainly
}
}
Rationale. Guards were part of the value role removed in (a.i) and share its fate: a guard is an IF folded into the arm header, where it reads as part of the pattern rather than as the branch it is. Written inside the body it is the same code, one construct fewer, and the arm's binding is already in scope.
Part I.H.vi — Conditional compilation
(a) Rule. WHEN is reused for compile-time conditional blocks, resolved entirely at build time. An excluded block is removed before semantic analysis and never type-checked, so it costs nothing at runtime. Three forms exist. A platform test against the System vocabulary; a build-mode test against the reserved Build enum, with its DEBUG and RELEASE values; and a feature-flag test, WHEN FEATURE "name", against flags passed to the compiler with --feature. Conditional-compilation WHEN blocks may nest and may appear at any point in a source file.
Example.
WHEN Build IS DEBUG {
printline("verbose diagnostics")
}
ELSE {
// release path — the DEBUG arm above is removed before analysis
}
WHEN FEATURE "experimental" { enableExperimental() } ELSE { } // --feature experimental
Rationale. Resolving these at build time means an excluded arm is never analyzed or emitted. Debug-only or feature-gated code carries no runtime cost, and cannot even fail to type-check in a build where it is switched off.
(a.i) Rule. The grammar is positive-identity only (WHEN Build IS DEBUG / WHEN Build IS RELEASE, each with the mandatory ELSE) — no build-identifier algebra.
Example.
WHEN Build IS RELEASE { ... } ELSE { ... } // positive identity, mandatory ELSE
WHEN Build IS NOT DEBUG { ... } // ERROR — no negation / build-identifier algebra
Rationale. Restricting the grammar to a positive identity test with a mandatory ELSE keeps every conditional-compilation site to two plainly-labeled arms. It avoids the tangle of boolean build-flag algebra that makes preprocessor conditionals hard to read.
(a.ii) Rule. Status (2026-06-26): the build-mode form — WHEN Build IS DEBUG/RELEASE — is implemented (compiler/analyze/build_conditionals.py splices the live arm before analysis; posture is RELEASE iff -prod/--build release, else DEBUG). It is resolved at statement level (the V1 target — debug-only code paths). The platform and WHEN FEATURE forms remain specified-but-not-yet-implemented, as does declaration-level conditional compilation (whole classes/methods).
Rationale. This records the shipped-versus-specified split. The build-mode form is live today at statement level; the platform, feature-flag, and declaration-level forms are still on the roadmap.
Part I.H.vii — Unreachable code
(a) Rule. A statement that can never execute, because the statement directly before it in the same block unconditionally leaves that block, is a compile error (E4001). The unconditional terminators are RETURN, a multi-value return, PANIC, UNREACHABLE!, BREAK, and CONTINUE. Any statement following one of these within the same block is dead, and must be removed or reordered.
Example.
METHOD f() RETURNS int32 {
RETURN (1)
log("done") // ERROR (E4001) — dead, directly after RETURN
}
FOR item IN items {
BREAK
process(item) // ERROR (E4001) — dead, directly after BREAK
}
Rationale. Unreachable code is a logic error rather than a style nit: the author plainly meant something other than what they wrote. That is why it blocks the build rather than producing an advisory.
(b) Rule. The rule is deliberately direct-only. It does not treat a compound construct whose every arm terminates — an IF in which both branches RETURN — as a terminator. Proving that is the separate concern of definite-return analysis (I.K).
Example.
IF (c) { RETURN (1) } ELSE { RETURN (2) }
log("after") // NOT flagged by E4001 — the IF is not a direct terminator
Rationale. Keeping E4001 to the directly-preceding statement makes it cheap and unambiguous; whether an all-arms-terminating construct leaves the block is the province of definite-return analysis (I.K), not this rule.
Part I.H.viii — Linting (envzn lint)
(a) Rule. Beyond the errors above, the compiler exposes a report-only linter, envzn lint, that runs the parser and analyzer over a module and stops before code generation. It surfaces the same errors a build would, plus an opt-in set of advisory diagnostics a normal build does not raise. These are the W10060-range "lint" codes: empty control-flow blocks, a constant IF condition, a long IF/ELSE IF cascade better replaced by polymorphism, and the pre-existing style advisories.
Example.
IF (TRUE) { ... } // W10060-range — constant IF condition
WHILE (more) DO { } // W10060-range — empty control-flow block
Rationale. Running through analysis but stopping before code generation lets the linter surface style-level smells: dead-obvious constant conditions, empty blocks, an IF cascade that wants to be a MATCH. None is a defect a build must reject, but each is worth an author's attention.
(b) Rule. Advisory severities are tunable per code through a lint block in the module manifest (off/info/warn/error), and --strict promotes every advisory to error severity. The surface is otherwise non-blocking, and never rewrites source.
Example.
// module manifest — lint block
"lint": { "W10060": "off" } // any W10060-range advisory, tuned to off/info/warn/error
// envzn lint --strict → every advisory promoted to error severity
Rationale. Per-code severity plus a --strict promotion lets a team dial each advisory to its own tolerance, without the linter ever blocking a build or editing source behind their back. The tool reports; the team decides.
Section I.I — Memory and Ownership
The ownership model that gives Envzn memory safety without a garbage collector: single ownership, the handle types, REFERENCE and the non-owning reference, the reference checker, parameter passing, CLEANUP, and the UNSAFE escape hatch.
(a) Rule. Envzn manages memory through ownership rather than through a garbage collector. Destruction is deterministic — it happens at a point a reader can identify from the source — and there is no collection pause at runtime. The rest of this section describes the discipline that buys those two properties — who owns what, how non-owning observation is expressed, and how the compiler proves the result safe.
Rationale. Ownership-not-GC is one of the language's fixed principles. The two properties it buys matter for application software; the price is a stricter authoring-time discipline, which is exactly what the rules below define.
Part I.I.i — Ownership
(a) Rule. A class instance is owned uniquely by default. The default owning handle is non-copyable at the type level, so an accidental duplication is a compile error rather than two owners of one instance. Ownership transfers: it moves at a := from a CREATE, from a method result, or from an owning place expression. Observation of an instance owned elsewhere is expressed by a non-owning reference. Where the compiler can prove the referent outlives the reference, that reference is a borrow: a checked pointer that keeps nothing alive, carrying no control block, no reference count, and no runtime test.
Example.
Widget w := CREATE Widget() // owns — constructed via :=
Widget w2 := makeWidget() // owns — moved from a method result
// Widget w3 = w // ERROR — owning handle is non-copyable; no two owners
REFERENCE Widget r =@ w // borrow — observes w, owns nothing
Rationale. Making the owning handle non-copyable turns "two owners of one instance" from a runtime hazard into a rejected program. Most observation is of the provable-outlives kind, so the borrow — the cost-free case — is the common case, and it costs nothing at runtime.
(b) Rule. A reference that escapes cannot be proven outlives-safe: one stored in a field whose referent is owned by a third party that may drop first. Rather than let it dangle, the compiler upgrades it to a strong handle — an atomically reference-counted owner that shares ownership and keeps the referent alive until the last strong handle drops. A strong handle is a last resort: where a borrow is provable the borrow is mandatory, and writing a strong handle in its place is a compile error (E3032).
Example.
// a field whose referent is owned elsewhere and may outlive nothing provable:
REFERENCE Channel chan // analyzer upgrades an escaping referent to a strong handle
// where a borrow IS provable, writing the escaping form instead is rejected → E3032
Rationale. Reference counting has a cost, so it is paid only on the irreducible escaping residue the borrow proof cannot reach — never where a borrow would do.
(b.i) Rule. Shared ownership reintroduces the one hazard unique ownership had ruled out: a cycle of strong handles can never reach a last drop, and so can never be freed. The compiler closes it statically — the graph of owning and strong edges must be acyclic, and a strong-reference cycle is a compile error (E3033). Owning recursion, where a node owns the next node in a list or tree, is acyclic at the object level and is not a cycle in this sense.
Example.
// LinkedList node owns the next node — owning recursion, acyclic, accepted:
TEMPLATE: GIVEN TYPE T IS Cloneable, PRIMITIVE (EXCEPT boolean, opaque, complex, number):
CLASS Node[T] {
T value
Node[T] next // owning edge down the list — OK
}
// two objects each holding a STRONG handle to the other → E3033 (strong-reference cycle)
Rationale. Because a strong cycle is the one structure refcounting cannot reclaim, catching it at compile time makes "no leaks" a property proven rather than hoped for. A uniquely owned instance is still destroyed deterministically when its owner drops. A strongly shared instance is destroyed at the last strong drop — a point the source still identifies, though it is the last of several rather than a single one.
(c) Rule. A borrow observes; it does not own, and cannot be made to own. Where a parameter takes ownership of a class value by value, the argument must be an owned value rather than a borrow. That covers the ordinary by-value class parameter and the MOVE parameter that consumes its argument. A borrowed argument — an element read as arr[i], or any value held through a REFERENCE — points at storage owned elsewhere. It can be neither moved nor copied into an owning parameter, so passing one is a compile error (E3038). The remedy is to hand over an owned value — ->clone() (when the type is Cloneable) or ->deepCopy() — or, when the callee only reads, to declare the parameter REFERENCE T.
Example.
// take(MOVE Widget w) consumes its argument:
REFERENCE Widget r =@ shelf[0]
// take(r) // ERROR E3038 — a borrow cannot be moved/copied into an owning param
take(shelf[0]->clone()) // OK — hand over an owned clone
take(shelf[0]->deepCopy()) // OK — hand over an owned deep copy
observe(shelf[0]) // OK — observe(REFERENCE Widget w) only reads the borrow
Rationale. An owning parameter must end up owning a value; a borrow owns nothing, so silently copying it would hide an allocation the reader can't see. Requiring an explicit ->clone()/->deepCopy() — or switching the parameter to REFERENCE — keeps the cost visible at the call site.
(c.i) Rule. The text family is exempt: a by-value String or ByteBuffer parameter binds a read-only borrow of the referent, so a borrowed value may be passed without a copy. The exemption follows the parameter's lowered shape, not its type name, and so does not extend to a MOVE parameter of either type — MOVE is an ownership transfer and takes the owning handle, which a borrow cannot produce.
Example.
REFERENCE String s =@ names[0]
greet(s) // OK — by-value String param borrows read-only; no copy, no E3038
consume(s) // ERROR E3038 — `consume(MOVE String)` takes ownership
Rationale. String and ByteBuffer are immutable (I.D.vi) and expose no MODIFY method, so a by-value parameter of either can borrow read-only without any risk the caller's value is mutated behind its back. MOVE is the exception to the exception: it consumes its argument, so a borrow cannot satisfy it and E3038 still applies. The three shapes are given in the parameter-ABI table of ENVZN_IR_SPEC.md: by-value and REFERENCE both lower to const T*, while MOVE lowers to the owning handle.
(d) Rule. Class instances live on the heap. Primitives, String, STATUS values, enum cases, and STRUCT fields are value types and live on the stack, or inline within whatever contains them. When a stack frame exits — by an ordinary return, the end of a block, or exception unwinding — the owned heap objects it held are released in reverse declaration order. Non-owning handles are skipped during that release, because they never owned anything.
Example.
METHOD run() {
Widget a := CREATE Widget() // owned heap object
Widget b := CREATE Widget() // owned heap object
REFERENCE Widget r =@ a // non-owning — skipped at release
} // frame exits: b released, then a (reverse decl order); r skipped
Rationale. Reverse-declaration-order release means a later object (which may reference an earlier one) is torn down first. The owner is responsible for release, and only the owner — so a non-owning handle must never trigger one.
DUPLICATE and the implicit duplication of a CONSTANT.
DUPLICATE x yields an independent deep copy of x, whatever storage shape x
has: an owning handle clones its referent, a value class or primitive copies.
It is the explicit form of "I want my own".
A CONSTANT read in an owning position duplicates implicitly — no
DUPLICATE is written, and none is required:
CONSTANT String tag = "date"
METHOD nameOf() RETURNS String {
RETURN (tag) // implicit duplicate; `DUPLICATE tag` is redundant
}
.slots[.slots.length] := tag // likewise
Rationale. An explicit ownership annotation earns its place by carrying
information the compiler does not already have. For an ordinary binding it does:
x may legitimately be moved or copied, so DUPLICATE / := records which
the author meant. For a CONSTANT there is exactly one legal answer — a copy —
because a move would gut a shared, immutable binding that every later reader
still depends on. The annotation conveys nothing, so requiring it is ceremony,
and by I.A the compiler is the side that should absorb it.
The owning positions are the three where a value's ownership is decided: the
initializer of a := binding, the value of a := assignment (including a cell
store), and a by-value RETURN. A borrow position is untouched — passing a
CONSTANT to a REFERENCE T or a bare type-parameter parameter hands over a
pointer and copies nothing.
Part I.I.ii — Handle types
(a) Rule. A binding of a class type is reached through a handle, and Envzn has three handle shapes. They apply uniformly to every kind of value — there is no separate handle vocabulary for primitives, for value classes, or for collections.
| Handle | Owning | Mutable | Description |
|---|---|---|---|
T x |
yes | yes | Owns its value. Reading, writing, and moving out of x are all permitted. |
REFERENCE T x |
no | no | An immutable reference — read-only observation of storage owned elsewhere. Always valid, by the reference checker's guarantee. |
MUTABLE REFERENCE T x |
no | yes | A mutable reference — exclusive write access to storage owned elsewhere. Always valid, by the reference checker's guarantee. |
Example.
Counter c := CREATE Counter() // T x — owns
REFERENCE Counter ro =@ c // read-only borrow
MUTABLE REFERENCE Counter rw =@ c // exclusive-write borrow
Rationale. One handle vocabulary for every value kind means a reader learns the ownership/mutability of a binding from its handle shape alone, with no per-type special cases to memorize.
(b) Rule. Optionality is not encoded in a handle, because there is no ? suffix; absence, where it applies, is the EMPTY state described in I.D.iv. An owning class handle may be EMPTY, and a read of it requires that presence first be established. An owning primitive, STATUS, enum, or struct value always holds a defined value and has no EMPTY state. A reference of either kind is always valid by the reference checker's guarantee, and so needs no presence check at all.
Example.
Widget w := EMPTY // owning class handle may be EMPTY
IF w IS VALID THEN { w->use() } // presence established before the read
int32 n = 0 // primitive — no EMPTY state ever
REFERENCE Widget r =@ shelf[0] // always valid — no presence check needed
Rationale. Folding absence into a ? suffix would duplicate what the EMPTY state already expresses. Keeping it out of the handle means the three handle shapes stay about ownership and mutability, and nothing else.
Part I.I.iii — Where IS VALID applies
(a) Rule. Because the EMPTY state applies only to some bindings, the presence test IS VALID is meaningful only on those. Applying it elsewhere is a compile error, with a diagnostic that names the correct alternative. The table below is the complete account.
Subject of IS VALID |
Applies | Reason |
|---|---|---|
| Owning class handle | yes | May be EMPTY if assigned EMPTY or never assigned. |
String / ByteBuffer |
no | Always present (I.D.vii.b) — an empty string is the present value "", not absence, so the test could never be false. Test the content with ->isEmpty(). Diagnostic E6068. |
| The value slot of a pipe-XOR return | yes | The slot is populated only on the success branch. |
REFERENCE T or MUTABLE REFERENCE T |
no | Always valid by the reference checker's guarantee. |
| A primitive value | no | Always holds a defined value. |
A STATUS value |
no | Always holds a defined status — test with IS SUCCESS / IS FAILURE / IS PARTIAL_SUCCESS. |
| An enum value | no | Always holds a defined case. |
A STRUCT value |
no | Always holds defined member values. |
Example.
IF handle IS VALID THEN { handle->use() } // OK — owning class handle
// IF status IS VALID { ... } // ERROR — use IS SUCCESS / IS FAILURE on a STATUS
// IF r IS VALID { ... } // ERROR — a REFERENCE is always valid
Rationale. IS VALID tests the one axis — presence — that only EMPTY-capable bindings have. Naming the correct alternative in the diagnostic steers a STATUS to IS SUCCESS/IS FAILURE rather than leaving the author to guess.
Part I.I.iv — REFERENCE and the non-owning reference
(a) Rule. A REFERENCE T is a non-owning, immutable reference to a value; a MUTABLE REFERENCE T is a non-owning, exclusive-write reference. Both work uniformly across every T. A reference is a typed pointer whose lifetime the compiler has checked. It carries no control block, no reference count, and no runtime validity test, because the reference checker has proved at compile time that it is sound.
Example.
REFERENCE int32 ro =@ scores[0] // immutable reference to a primitive
MUTABLE REFERENCE Widget rw =@ w // exclusive-write reference to a class
Rationale. Because soundness is a compile-time proof, a reference lowers to a bare typed pointer with none of the runtime machinery a checked-at-runtime scheme would need.
(b) Rule. A reference is established with =@ against a place expression, and read directly thereafter with no presence check. Writing through a MUTABLE REFERENCE to a primitive looks like an ordinary assignment with the reference on the left; writing through one to a class field uses ordinary field and method syntax. REFERENCE composes with the access modifiers and may appear on class fields, on method parameters and returns, and on locals. MUTABLE REFERENCE may appear on parameters, returns, and locals, but not on a class field.
Example.
MUTABLE REFERENCE int32 m =@ counts[0]
m = m + 1 // write through a MUTABLE REFERENCE to a primitive
MUTABLE REFERENCE Widget mw =@ w
mw->recolor(RED) // write through one to a class — ordinary method syntax
// CLASS C { MUTABLE REFERENCE Widget field } // ERROR — MUTABLE REFERENCE not allowed on a class field
Rationale. A mutable reference stored in a field would let the class retain exclusive write access past the reference's checked lifetime. That is exactly the property the checker exists to prevent, so the one position is forbidden.
(b.i) Rule. There is one further parameter-position restriction. A MUTABLE REFERENCE to a scalar primitive — boolean, int32, float64, and the rest, but not an array, a class, or a wrapper collection — is not a valid parameter (E2101). The restriction is narrow: MUTABLE REFERENCE int32[] (a mutable array), MUTABLE REFERENCE SomeClass (a mutable container), and a MUTABLE REFERENCE int32 local bound to a mutable element handle all remain valid. Only the scalar-primitive parameter is rejected.
Example.
// METHOD bump(MUTABLE REFERENCE int32 n) // ERROR E2101 — scalar-primitive out-param
METHOD bump(int32 n) RETURNS (int32 n, boolean ok) // OK — pass value, return updated value
METHOD sort(MUTABLE REFERENCE int32[] a) // OK — mutable array param
MUTABLE REFERENCE int32 m =@ arr->mutableAt(i) // OK — mutable element handle, a local
Rationale. Mutating a scalar through a reference parameter is the wrong idiom. The correct shape is to pass the value and return the updated one, which the multi-return form expresses directly: RETURNS (int32 n, boolean ok) rather than a MUTABLE REFERENCE boolean ok out-parameter.
(c) Rule. There is one carve-out: SHARED MUTABLE REFERENCE may appear on a class field, provided the referent type T is a SHARED CLASS (I.K.ii) — a referent that is not is E2117. A SHARED MUTABLE REFERENCE field is bound at INIT, and its disposition follows I.I.i. If the referent provably outlives the holder, the field is a borrow and lowers to a non-const T* at no cost. If it escapes, the field is a strong handle and lowers to _ev_shared<T>. Either way the field is always valid where it is in scope, and calling the monitor's mutating methods through it is ordinary method syntax.
Example.
CLASS Subscription[T] {
// Channel is a SHARED CLASS; a subscription may outlive the broker →
// this reference escapes and is a strong handle (_ev_shared<Channel>):
PRIVATE SHARED MUTABLE REFERENCE Channel[T] chan
INIT(MUTABLE REFERENCE Channel[T] c) { .chan =@ c }
}
// SHARED MUTABLE REFERENCE PlainClass f // ERROR E2117 — referent is not a SHARED CLASS
Rationale. A MUTABLE REFERENCE field is forbidden because it would alias exclusive write access; SHARED waives that exclusivity, and doing so is sound only because a SHARED CLASS is a monitor that serialises its own access, so concurrent aliased mutation cannot race. The waiver is narrow — only exclusivity is given up; lifetime remains the analyzer's concern. This is the mechanism behind Broker[T] / Subscription[T], where each subscription holds a SHARED MUTABLE REFERENCE to a broker-owned Channel and the channel survives until the last subscription drops.
(d) Rule. A reference's lifetime begins at its =@ and ends at the last use of the reference in its scope, and the referent must outlive that span. While a reference is alive, its source cannot be moved, dropped, replaced, or — for a collection source — structurally mutated. A reference local may be rebound with a fresh =@ to a different source, provided the previous reference's lifetime has already ended at the point of rebinding.
Example.
REFERENCE int32 r =@ scores[0] // reference established
// scores->append(99) // ERROR — cannot mutate scores while r is alive
printline((r INTO String)) // last use of r — its lifetime ends here
scores->append(99) // permitted now — r's lifetime has ended
r =@ scores[1] // OK — rebind after the previous lifetime ended
Rationale. Bounding a reference's lifetime by its last use, and freezing its source for that span, is what lets the checker prove the referent still exists at every read. No runtime validity test is needed.
(e) Rule. A reference field is subject to structural conditions at INIT time, and those conditions decide the field's disposition. The referent is a provable borrow in two cases: when it is ownership-reachable through a chain of owning fields from the enclosing instance, or when it is a constructor parameter locked against rebinding for the instance's lifetime. A field that satisfies either is a borrow, and must be written as one. A field that satisfies neither has an escaping referent. The analyzer upgrades it to a strong handle when the referent type is shared-eligible — a SHARED CLASS, by the E2117 rule above — and rejects it as unpromotable (E3035) when it is not.
Example.
CLASS Cursor {
PRIVATE Buffer buf // owning field
PRIVATE REFERENCE Buffer cur // ownership-reachable through .buf → provable borrow
INIT() { .buf := CREATE Buffer() .cur =@ .buf }
}
// an escaping reference field onto a non-shared, non-shared-eligible type → E3035 (unpromotable)
Rationale. Deciding disposition from the field's shape discharges the common case cheaply, before any flow analysis is needed: a borrow where a borrow is provable, a strong handle only where the referent genuinely escapes onto a shared-eligible type.
(e.i) Rule. An escaping reference into value storage — a field reference into a T[], {K : V}, {T}, or char[], where there is no per-element heap object to reference-count — cannot be made safe and is forbidden in V1 (E3034); references of that shape are confined to the scope they are created in, as the collection iterators of I.J are. Where the structural conditions cannot decide a field-stored reference's fate they leave it to a runtime check. But a case the flow-sensitive escape walk of I.I.v can prove does dangle is rejected outright (E3040): a reference to a method-local that the enclosing instance outlives, where the local is freed at method exit while the field lives on and is not moved into the instance's ownership in the same body.
Example.
// PRIVATE REFERENCE int32 pick =@ scores[i] // ERROR E3034 — reference into value storage stored in a field
METHOD attach() {
Widget local := CREATE Widget()
// .held =@ local // ERROR E3040 — field outlives a method-local it references
}
Rationale. Value storage holds no per-element heap object to reference-count, so an escaping reference into it can neither borrow nor be promoted — the only safe answer is to confine it. E3040 is the flow walk's floor-advancing complement to the structural conditions: it rejects a proven dangle rather than deferring it to a runtime check.
Part I.I.v — The reference checker
(a) Rule. The reference checker is the compile-time analysis that enforces the rules above; it is part of the analyzer pipeline and adds nothing to the running program. Three core rules govern it. First, a reference does not outlive its referent: every reference's lifetime is bounded by its last use, and the referent must outlive it. Second, shared exclusive-or mutable: for any one source, at any one time, either any number of immutable references exist or exactly one mutable reference exists, never both. Third, no mutation through another path while referenced: while any reference to a source is alive, the source may not be moved, dropped, or mutated by any other route, structural mutations of a collection included.
Example.
REFERENCE int32 a =@ data[0]
REFERENCE int32 b =@ data[0] // OK — many immutable references may coexist
// MUTABLE REFERENCE int32 m =@ data[0] // ERROR — a mutable reference may not coexist with them
Rationale. These three rules are the classic aliasing discipline (many-readers-xor-one-writer, plus no-mutate-behind-a-reference) applied at compile time, which is what makes every checked reference safe to lower to a bare pointer.
(b) Rule. Beyond enforcing those rules on borrows, the checker assigns each stored reference its disposition and proves the program leak-free. The disposition decision is borrow-mandatory-where-provable. For every reference field and reference return, the checker asks whether a borrow is provable; where it is, the borrow is required. A strong handle written where a borrow would do is rejected (E3032). A strong handle is permitted only on the escaping residue the proof cannot reach, and only onto a shared-eligible referent. An escaping reference onto a type that cannot be made shared-eligible is unpromotable (E3035); one into value storage is forbidden outright (E3034). The leak-freedom proof is an acyclicity check over the combined graph of owning and strong edges: a strong-reference cycle is a compile error (E3033), while owning recursion is recognized as acyclic and passes.
Example.
// E3032 — strong handle where a borrow is provable (borrow was mandatory)
// E3035 — escaping reference onto a non-shared-eligible type (unpromotable)
// E3034 — escaping reference into value storage
// E3033 — a cycle of strong edges (leak the acyclicity check catches)
Rationale. Together these make "no leaks" a theorem the compiler discharges rather than a runtime behavior the program merely tends to exhibit. The set of borrows the checker is willing to prove is held to a documented baseline. Otherwise an improvement to the proof would retroactively turn a previously accepted strong handle into an E3032 error.
(b.i) Rule. Two advisories round out the surface, both non-blocking. The checker reports each site where it promotes a reference to a strong handle (W10092), so the cost ARC takes on is never silent, and it flags a reference held past its last use (W10091).
Example.
// W10092 — advisory: this reference was promoted to a strong (reference-counted) handle
// W10091 — advisory: this reference is held past its last use
Rationale. Promotion to ARC has a real cost, and a held-too-long reference over-freezes its source; surfacing both as warnings keeps them visible without blocking a build.
(c) Rule. For a method that returns a reference paired with a STATUS in the pipe-XOR shape, the returned reference is valid only inside the success branch of the consuming IF. A read through it elsewhere is a compile error. Method signatures that return a reference rely on lifetime elision. A method taking one reference parameter and returning a reference infers the return's lifetime from that parameter; a method whose receiver is a reference infers it from the receiver. Where elision is genuinely ambiguous, as with two reference parameters, the compiler reports an error and asks for an explicit annotation, a syntax reserved for V2.
Example.
IF cache->lookupRef(key) THEN {
REFERENCE Value v =@ $= // valid only inside the success branch
v->use()
}
// reading through that reference outside the success branch is a compile error
Rationale. The pipe-XOR value slot is populated only on success, so its reference is only meaningful there. Lifetime elision covers the one- and receiver-reference cases so that V1 needs no explicit lifetime annotation syntax at all; the genuinely ambiguous multi-reference case is deferred to V2 rather than guessed.
(c.i) Rule. The keyword VOLATILE is reserved for a future runtime-checked observer, for the cross-thread sharing patterns the static checker cannot prove safe; V1 has no such observer, and a pattern that would need one is expected to be redesigned through channel-mediated communication. The reference checker is not a runtime check, requires no explicit lifetime annotations in V1, and leaves code that uses only owned handles and value copies entirely unaffected.
Rationale. Naming what the checker is not forecloses the common misreadings. Code that never takes a reference pays nothing, and cross-thread aliasing the static proof cannot reach is routed to channels rather than an unproven VOLATILE in V1.
(d) Rule. A reference whose safety turns on flow — whether a referent actually outlives a holder along every path — is beyond a shape rule. For the field-stored escaping residue the compiler adds a flow-sensitive escape walk: a whole-program analysis running after the intermediate representation is built and before code is emitted, the last memory-safety diagnostic in the pipeline. It classifies each field-stored reference by the lifetime of what it names: reachable from the enclosing instance, a constructor parameter, a local moved into the instance's ownership, or a local that merely dies at the method's end. Where it proves the last, it rejects the store as a dangle (E3040) rather than leave it to a runtime check. The walk is additive: it fires only on residue the structural conditions do not already resolve, and never revokes a reference they accept.
Example.
METHOD attach() {
Widget local := CREATE Widget() // dies at method exit
// .held =@ local // ERROR E3040 — the field outlives this local
.held := local // OK — move the local INTO the instance's ownership instead
}
Rationale. Shape rules decide the common case cheaply; the flow walk advances the floor on the residue they can't, turning a provable dangle into a rejected program instead of a deferred runtime check. In V1 it reaches within a single module. The cross-module case — a referent behind a dependency's boundary — is resolved by the same walk once dependency bodies are carried in the .evir sidecar. Until then, a reference whose safety only that whole-program analysis could establish is rejected rather than assumed safe.
Part I.I.vi — Parameter passing
(a) Rule. Envzn has no address-of operator and no parameter marker at the call site: the semantics of passing an argument are determined entirely by the parameter's declared type. There are four mutually-exclusive parameter markers — plain T, REFERENCE T, MUTABLE REFERENCE T, and MOVE T; a parameter is exactly one of them. A plain class parameter T is an owned class handle the callee may read, and may mutate through its methods, but may not take ownership of. It cannot move the handle out, store it in a longer-lived owner, or return it. A REFERENCE T may be read and have its read-only methods called; calling a mutating method or writing a field through it is a compile error. A MUTABLE REFERENCE T may be read, written, and have any method called, and the checker guarantees no other reference to the same source coexists with it. A MOVE T parameter is the one shape that does take ownership. The caller's source is consumed by the call and the source name is poisoned afterward, so a later use of it at the call site is a compile error.
Example.
METHOD read(Widget w) // plain — read/mutate-via-methods, may not take ownership
METHOD observe(REFERENCE Widget w) // read-only; a mutating call through w is a compile error
METHOD edit(MUTABLE REFERENCE Widget w)// read + write + any method; exclusive for the call
METHOD store(MOVE Widget w) // takes ownership
Widget w := CREATE Widget()
store(w) // w consumed here
// w->use() // ERROR — w is poisoned after a MOVE
Rationale. Determining pass semantics from the declared type alone — no &, no call-site marker — keeps the call site readable. The reader learns what a call does to its argument from the signature, not from punctuation at the call. MOVE cannot combine with either reference form because a reference by definition cannot consume. None of plain/REFERENCE/MUTABLE REFERENCE extends its handle's lifetime past the call; MOVE takes the handle into the callee where its lifetime begins anew. A primitive, value class, STRUCT, STATUS, or enum is passed by value. The C++ lowering of each parameter shape is given in ENVZN_IR_SPEC.md C.ii.
(a.i) Rule. The MOVE marker exists primarily for the collection-insertion methods of I.J.ii: append, prepend, push, enqueue, insert, add. It makes the move semantics visible at the signature itself, and the analyzer reads the marker rather than the method name to decide which call sites poison their argument.
Example.
Widget w := CREATE Widget()
list->append(w) // append's parameter is MOVE → w is poisoned after this call
// w->use() // ERROR — poisoned by the MOVE-marked append
list->appendCopy(other->clone()) // the copy family instead takes an owned copy
Rationale. Keying poisoning off the visible MOVE marker, rather than a memorized list of method names, means an author can see at the signature whether a call consumes its argument. A new insertion method gets the semantics right for free by carrying the marker.
Part I.I.vii — CLEANUP
(a) Rule. Every class has an implicit destructor that frees its heap memory. A class may additionally define a CLEANUP block for teardown the language cannot perform on its own — closing a file handle, releasing a GPU buffer, shutting a socket. CLEANUP runs automatically when the owner's stack frame exits, exception unwinding included. The destruction order is fixed. First the instance is marked no longer alive, so any concurrent observation sees absence. Then the developer-defined CLEANUP block runs. Then the member fields are destructed in reverse declaration order, each running its own destructor chain.
Example.
CLASS FileWriter {
PRIVATE opaque handle
CLEANUP {
.closeHandle() // runs automatically at frame exit, unwinding included
}
}
Rationale. The implicit destructor covers memory; CLEANUP covers the resources the compiler can't see. Fixing the order — mark-dead, then CLEANUP, then fields in reverse — means a field a CLEANUP body relies on is still alive while that body runs. Concurrent observers see absence the instant teardown begins.
(a.i) Rule. An attempt inside CLEANUP to observe a self-reference always sees absence, because the not-alive mark has already been set, and the analyzer rejects any code path in CLEANUP that would try.
Example.
CLEANUP {
// IF SELF-observing path ... // ERROR — analyzer rejects observing a self-reference in CLEANUP
.releaseBuffer() // OK — act on fields, not a self-observation
}
Rationale. Because the not-alive mark is set first, any self-observation in CLEANUP would necessarily see absence — a bug a teardown routine reliably gets wrong. The analyzer rejects the path rather than let it read absence at runtime.
Part I.I.viii — UNSAFE handles and the UNSAFE block
(a) Rule. The memory model so far — owning handles, references, and the reference checker — describes storage the compiler can reason about. Two constructs deliberately step outside that reasoning, for the narrow case of holding and reaching a raw pointer that originates across the foreign-function boundary (I.N, ENVZN_IR_SPEC.md C.vi). Both carry UNSAFE in their name precisely so the escape is greppable and never silent.
Rationale. Some FFI storage is genuinely beyond the compiler's reasoning; making every such escape carry UNSAFE in its name keeps the two constructs auditable by grep rather than silent.
(b) Rule. The UNSAFE.<NAME> handle family. UNSAFE is a runtime-adjective prefix on a small set of primitive handle types: UNSAFE.HANDLE is an opaque heap pointer, UNSAFE.MUTEX an OS mutex handle, and UNSAFE.CONDVAR an OS condition-variable handle — the three valid names, with any other UNSAFE.X rejected by name. The prefix parses as the bare handle type carrying an UNSAFE runtime adjective, and the type lowers to the kernel's null-checked _UnsafeHandle<T> wrapper (<void>, <pthread_mutex_t>, <pthread_cond_t> respectively). Unlike a bare opaque, an UNSAFE.<NAME> value is therefore null-checked: a dereference of a null handle does not corrupt memory but raises a NullPointerException (I.M.ii), catchable like any kernel error. An UNSAFE.<NAME> value may be stored in a PRIVATE or INTERNAL class field and passed across a FOREIGN BIND boundary. DIRTY, SECRET, and ENCRYPTED are reserved sibling adjectives; UNSAFE is the only one implemented in V1.
Example.
CLASS Mutex {
PRIVATE UNSAFE.MUTEX handle // lowers to _UnsafeHandle<pthread_mutex_t>, null-checked
INIT() { .handle = pthread_mutex_create() }
CLEANUP { pthread_mutex_destroy(.handle) } // ERROR - check MUTEX for exact syntax
}
// UNSAFE.SOCKET s // ERROR — not one of HANDLE / MUTEX / CONDVAR
Rationale. This is the carrier the kernel's lock-free atomics (AtomicBoolean, AtomicInt32, …), Mutex, and ThreadCondition use to own their C-level handle across the object's lifetime — created in INIT, destroyed in CLEANUP. The null-check is the safety margin over a bare opaque: a null dereference becomes a catchable NullPointerException rather than memory corruption.
(c) Rule. The UNSAFE { … } block. A FOREIGN_TYPE BIND handle — an Envzn name bound to a concrete C struct or type (ENVZN_IR_SPEC.md C.vi) — lowers to a raw, untracked C pointer. Reaching through it, reading or writing a field like p.ai_family, is a memory-unsafe operation whose validity the compiler cannot vouch for. Every such access must sit lexically inside an UNSAFE { … } block; a bare access outside one is diagnostic E3036. The block is purely a compile-time gate, lowering to an ordinary C++ scope with no runtime effect. The gate does not cross a task boundary: a CONCURRENT or PARALLEL body nested inside an UNSAFE block is not itself unsafe, because a spawned task does not inherit the lexical scope.
Example.
UNSAFE {
int32 fam = p.ai_family // OK — field access through a FOREIGN_TYPE handle, gated
}
// int32 fam2 = p.ai_family // ERROR E3036 — bare access outside an UNSAFE block
Rationale. Reaching through a raw C pointer is exactly the operation the compiler can't vouch for, so it must be lexically fenced and greppable. The gate is compile-time only (no runtime cost) and does not follow a spawned task, because the task's body is a new lexical scope that never inherited the fence.
(c.i) Rule. A companion confinement rule keeps the raw pointer from escaping: a FOREIGN_TYPE handle may not be a class field, a method parameter, or a return type (E3037) — it lives only as an UNSAFE-block-scoped local. A C handle that must persist across calls is stored instead as an opaque field — the confinement-legal carrier of I.D.v — and rehydrated inside an UNSAFE block. Within the block the compiler auto-wraps and unwraps between an opaque and the typed handle at each FOREIGN call boundary, so the field is read and assigned directly as though it were the typed pointer. Outside the block no such conversion is offered.
Example.
CLASS TlsConnection {
PRIVATE opaque ssl // persistent SSL* held as opaque (confinement-legal)
METHOD send(ByteBuffer data) {
UNSAFE {
SSL_write(.ssl, data) // .ssl auto-rehydrates to the typed handle in-block
}
}
}
// CLASS C { PRIVATE FOREIGN_TYPE addrinfo ai } // ERROR E3037 — FOREIGN_TYPE handle as a field
Rationale. Confining the raw handle to UNSAFE-block locals stops an untracked pointer from leaking into fields, parameters, or returns where the checker could not follow it. The opaque/typed auto-wrap lets a handle persist across calls without ever exposing the raw pointer outside the fence. The Networking TLS and socket layers are the worked examples — persistent SSL* / SSL_CTX* handles held as opaque, every OpenSSL and POSIX call made inside UNSAFE.
(d) Rule. Choosing between an UNSAFE.<NAME> handle and an opaque field. Both persist a foreign handle across calls, and they are not interchangeable — the choice follows the handle's shape. An UNSAFE.<NAME> field carries a raw, untyped C pointer of one of three blessed kinds — a void*, an OS mutex, an OS condition variable — with no runtime type tag. It is handed directly to a FOREIGN BIND function, and its safety margin is the intrinsic null-check (b). An opaque field (I.D.v) carries a typed value behind a runtime type tag, plus a captured cloner. For FFI it holds a FOREIGN_TYPE BIND typed handle such as an SSL*, which the compiler auto-rehydrates to and from the opaque at each FOREIGN call inside an UNSAFE block (c.i). A bare opaque — one carrying no FOREIGN_TYPE type — likewise auto-peeks to a raw void* when handed to a FOREIGN BIND opaque/void* parameter inside an UNSAFE block. So an opaque can also carry an untyped void* across the boundary with no cast, and the distinction from UNSAFE.HANDLE is then not capability but cost outside the UNSAFE block. Outside the UNSAFE block, an opaque still pays for its heap box, runtime tag, and captured cloner, however inside an UNSAFE block, opaque type can be assigned directly. A raw pointer read on a hot path with no type to track — a growable buffer's backing store, an atomic cell — belongs in UNSAFE.HANDLE, whose field is the bare pointer. opaque's tag earns its keep only where there is a type worth tracking. opaque is a better choice if you need to store a 'void' in a class data field. Reach for UNSAFE.HANDLE / .MUTEX / .CONDVAR when the field is a single raw pointer of one of those three kinds, owned INIT→CLEANUP and passed to bare C functions (the kernel's AtomicX, Mutex, ThreadCondition). Reach for opaque when the handle is a typed FOREIGN_TYPE pointer you want carried type-tagged and rehydrated, as with Networking's SSL* / SSL_CTX*. Reach for it also — this is opaque's primary* purpose, unrelated to FFI — when you need heterogeneous typed storage, such as a Dictionary holding a different value type per key.
UNSAFE.<NAME> |
opaque field |
|
|---|---|---|
| Carries | a raw, untyped C pointer | a typed value behind a runtime type tag + cloner |
| Blessed kinds | exactly three — HANDLE, MUTEX, CONDVAR |
any Envzn type; any FOREIGN_TYPE pointer |
| As a class field | yes (PRIVATE / INTERNAL) |
yes (confinement-legal carrier, I.D.v) |
Crossing FOREIGN BIND |
passed directly, as the pointer | auto-wrap/unwrap to the typed handle inside UNSAFE |
| Absence / failure | null deref → NullPointerException (I.M.ii) |
unloaded → unwrap pipe-XOR STATUS failure (I.D.v) |
| Primary use | one OS/C handle owned INIT→CLEANUP |
heterogeneous typed storage; persistent typed FFI handle |
Example.
// UNSAFE.HANDLE — a raw C pointer handed straight to FOREIGN BIND (the atomics / Mutex pattern)
CLASS AtomicInt32 {
PRIVATE UNSAFE.HANDLE native // an _Atomic int32 on the heap; a void* carrier
METHOD store(int32 v) { ev_atomic_i32_store(.native, v) } // passed directly across FOREIGN BIND
}
// opaque — a TYPED FOREIGN_TYPE handle carried across calls (the Networking TLS pattern)
CLASS TlsConnection {
PRIVATE opaque ssl // SSL* held type-tagged; confinement-legal
METHOD send(ByteBuffer data) {
UNSAFE { SSL_write(.ssl, data) } // .ssl auto-rehydrates to the typed SSL* in-block
}
}
// opaque's primary, non-FFI purpose — heterogeneous typed storage (I.D.v)
Dictionary[String, opaque, DefaultHasher[String]] props
props["port"] := opaque[int32]->wrap(8080) // each value a different type behind a tag
Rationale. The two carriers answer two different questions. UNSAFE.<NAME> answers "I hold one raw C handle of a known kind and want to pass it to C with a null-check for a safety margin"; opaque answers "I hold a typed value whose type the static system can't otherwise track" — whether that value is a FOREIGN_TYPE pointer persisted across calls or an ordinary Envzn value in a heterogeneous collection. Using opaque for a raw OS handle would pay for a type tag and wrap/unwrap ceremony the handle does not need. Using UNSAFE.HANDLE for a typed FFI pointer would throw away the tag that lets the compiler rehydrate it safely at the FOREIGN boundary. Matching the carrier to the handle's shape keeps each escape as small and as checked as it can be — the I.A "relocate the cost inward" tiebreak applied to the FFI surface.
Part I.I.ix — Borrow-propagating ("mirror") reference accessors
(a) Rule. A method's return reference-kind is normally fixed by its signature, which forces a type readable through both a const and a mutable receiver to declare two near-identical accessors. Envzn collapses that pair with a mirror accessor: a non-MODIFY method whose primary return is a MUTABLE REFERENCE. Its declared kind is a ceiling and the receiver's mutability a floor, so the reference a call yields is min(ceiling, receiver). A mutable receiver gets the writable MUTABLE REFERENCE; a const (REFERENCE) receiver gets a read-only REFERENCE. There is no call-site marker — selection is by the receiver's mutability alone, and the one source method serves both. A MODIFY method returning MUTABLE REFERENCE is not a mirror (it stays mutable-only), and a method returning a plain REFERENCE stays read-only always.
Example.
// Dictionary.lookupRef is a mirror: one accessor replacing lookupReference / lookupMutableReference
MUTABLE REFERENCE Widget mw =@ shelf[i] // mutable receiver → writable reference
REFERENCE Widget ro =@ frozenShelf[i] // const/REFERENCE receiver → read-only reference
Rationale. Selection by receiver mutability, with the declared kind as a ceiling and the receiver as a floor, lets one source method serve both const and mutable callers. It collapses the near-identical accessor twin: Dictionary.lookupRef in place of the former lookupReference / lookupMutableReference pair.
(b) Rule. Mirror selection rests on a single mutability predicate over the receiver. A receiver is const exactly when it is a REFERENCE borrow, or a SELF-rooted path inside a non-MODIFY method. It is mutable otherwise: a MUTABLE REFERENCE, an owned value, a plain value parameter (whose handle is shallow-const but whose pointee may be mutated, I.I.vi), or SELF inside a MODIFY method.
Rationale. Grounding the selection in one predicate — const iff REFERENCE-borrow or a non-MODIFY SELF-path, mutable otherwise — keeps a mirror's behaviour fully determined by the receiver at each call. There is no call-site marker to get wrong.
(c) Rule. Tightening a reference is always legal: a mirror's MUTABLE REFERENCE result may be bound (=@) into a REFERENCE. Loosening is always an error. A MUTABLE REFERENCE may not be =@-bound from a result the receiver's mutability narrowed to a read-only REFERENCE (diagnostic E6067), since that would widen a read-only borrow back to writable.
Example.
REFERENCE Widget ro =@ mutableShelf[i] // OK — tighten a MUTABLE REFERENCE result to REFERENCE
// MUTABLE REFERENCE Widget mw =@ frozenShelf[i] // ERROR E6067 — loosening a read-only borrow to writable
Rationale. Selection is realized by emitting a mirror as two C++ forms — const T* f() const and T* f(), and across an interface two virtual slots — so overload resolution picks the correct referent const-ness. The loosening rule is enforced in the analyzer (E6067) rather than merely by the C++ backend, so it holds for a backend without overload resolution.
Section I.J — Aggregates and Iteration
The fixed library of aggregate types — arrays, the eight collections, and the iterators they hand out — together with STRUCT and GROUP and the move/copy convention that governs putting values into a collection and taking them back out.
(a) Rule. An aggregate holds many values under one name. Envzn provides a fixed standard library of aggregate types — the array, eight collections, and the iterators they hand out — together with two further grouping constructs: STRUCT for a pure-data record and GROUP for a named set of related types. This library is closed: user code does not add a new aggregate — a new collection or storage engine. It may, however, declare a parametric class of its own, so long as that class wraps a library aggregate rather than reimplementing one. A program needing a specialized container — RingBuffer[T], a null-aware Column[T] — writes exactly such a class, holding a T[] or a collection as a private field. Such a class is subject to the bounded-genericity rules of I.K.ix: a mandatory TEMPLATE: qualifier, no unconstrained ANY, no compile-time metalanguage. Being an ordinary declaration, it may live in the kernel or in any module, and be instantiated across a module boundary through the module's exported headers.
Example.
int32[] primes // the array — foundation aggregate
Array[Token] parsed // one of the eight collections
STRUCT Point { int32 x; int32 y } // pure-data record
GROUP Shape { Circle, Rectangle } // named set of related types
FINAL CLASS RingBuffer[T] { // a specialized container = a class wrapping a library aggregate
PRIVATE T[] storage
// ...
}
Rationale. The library of aggregates is fixed, and the grouping constructs are few, because Envzn refuses a container metalanguage (bounded genericity, I.A): the low-level storage machinery lives in the kernel and is not re-invented. A user parametric class is not that machinery — it is a typed wrapper over it (RingBuffer[T] over T[]), which composes the fixed aggregates without enlarging them. Permitting the wrapper while closing the aggregate set keeps the surface small without denying the one construct — a container specialized to its element type — that data-domain modules such as mda genuinely need.
Part I.J.i — Arrays
(a) Rule. Envzn provides two array forms whose distinction is driven by the element type. A type T is blittable, defined formally in the Appendix, when its value can be copied by a flat byte-copy. That covers every primitive, and any STRUCT or VALUE CLASS whose every data field is transitively blittable. No member of the String family is blittable. A String is an owning handle to its storage, which the no-handles clause below excludes, and the same is true of DynamicString, ByteBuffer, and DynamicByteBuffer. A value class qualifies on its stored fields alone, provided it declares no EXTENDS: its methods and any IMPLEMENTS leave the value's bytes untouched, and so do not affect blittability. A blittable type has no handles to other instances and no REFERENCE fields; its value lives entirely in its own bytes. A plain heap CLASS instance is always an owning handle, so a CLASS[] is always the object-array, never a value-array. T[] is the array for every element type T: the storage backing divides on this property, and is otherwise transparent to the developer.
Example.
int32[] a // blittable element → value-array backing
Token[] b // non-blittable (Token is a class) → object-array backing
Rationale. A single spelling T[] covers every element type; the developer never chooses the backing. Blittability is what lets the value-array skip per-element ownership machinery, so the split is a performance decision the compiler makes, not a surface the developer manages.
(b) Rule. For blittable T, the syntax T[] (unbounded) and T[N] (fixed-capacity) declares a value-array: a contiguous buffer of T whose copy semantics are a deep value copy of every slot. Its API is deliberately minimal, and the developer never names its underlying class.
Example.
int32[] primes = [2, 3, 5, 7, 11] // contiguous, deep-value-copied on assignment
uint64[8] hash_state // fixed-capacity value-array
Rationale. The value-array is the form reached for whenever the elements are plain numbers, strings, or data records — the Ryu lookup table, the FNV constants, the bytes of a hash state, the rows of a fixed-shape struct. A flat byte layout is exactly what is wanted there, and the larger collection API would be dead weight.
(c) Rule. For non-blittable T — a class instance, an interface, a struct carrying references — T[] is an object-array. Each element is an owning handle, and copying the array clones every element, so the copy owns its own deep copies rather than aliasing the originals. The object-array exposes the same minimal surface as the value-array. Array[T], the class-typed collection wrapper whose richer surface (append, insert, map / filter / reduce, …) is described in I.J.ii, is layered over T[].
Example.
Token[] tokens // object-array — each slot an owning handle
Array[Token] parsed // the rich-API collection wrapper over Token[]
Rationale. Because both backings expose the same surface, T[] is genuinely the array for every element type. A developer reaches for Array[T] when the workload wants the larger API rather than the bare storage surface.
(d) Rule. The sole outward difference between a blittable T[] and an object T[] is how an element is relocated. Because Envzn keeps no class instance on the stack — every class value is a handle to a heap object — an object element is never implicitly value-copied. Moving one into a slot uses :=, which transfers ownership and empties the source, whether the source is another slot (a[i] := a[j]) or an owned local (a[i] := obj). A plain = between object slots is rejected, diagnostic E5025, because it would silently clone the handle; for a blittable element, where move and copy coincide, = is permitted. Reading an object element binds a reference (REFERENCE T x =@ a[i]) or takes a clone (T x := a[i]); a value element additionally permits a value read (T x = a[i]).
Example.
int32[] primes = [2, 3, 5, 7, 11] // value-array — blittable element; `=` writes
Token[] tokens // object-array — Token is a class; `:=` relocates
Array[Token] parsed // the rich-API collection wrapper over Token[]
tokens[0] := tokens[1] // move between slots — source emptied
tokens[2] := obj // move an owned local into a slot
tokens[3] = obj // ERROR — E5025: `=` would silently clone the handle
REFERENCE Token r =@ tokens[0] // non-owning read of an object element
Token t := tokens[0] // owned clone of an object element
int32 p = primes[0] // value read — permitted only for blittable elements
Rationale. The operator carries the meaning: := makes ownership transfer visible at every slot write, and rejecting = on object slots prevents a silent alias. Blittable elements can use = because for them move and copy are the same byte-copy, so nothing is hidden.
The value-array surface
(e) Rule. Both backings expose one surface: .length and .capacity as read-only fields, indexed access with [ ] through both reads and writes, a clear() method that resets length to zero while leaving capacity intact, an equals(other) method that reports whole-array value-equality (e.i), and an iterator() method that returns a forward Iterator OF T whose .item yields each element by value for a value-array and by reference for an object-array. Nothing else is on the surface — no append, no insert, no remove, no map / filter / reduce.
Example.
int32[] xs := CREATE(16)
int32 n = xs.length // read-only field
int32 c = xs.capacity // read-only field
xs[0] = 42 // indexed write
int32 v = xs[0] // indexed read
xs->clear() // length → 0, capacity kept
ValueIterator OF int32 it := xs->iterator() // forward iterator over the value-array
WHILE it->nextValue() DO { // pipe-XOR navigation (I.M.i)
printline($RETURNED)
}
Rationale. The array is the shape for a known, contiguous buffer. A workload that needs append, insert, or map is asking for Array[T], so those methods live there and not on the bare storage type.
(e.i) Rule. T[]->equals(other) reports value-equality: two arrays are equal when they have the same length and every element pair is equal. How an element pair is compared is fixed by the element type, and invisible at the surface. A value-array whose element T has a unique object representation — the integer, char, binary, and boolean types — compares by a single length-checked byte compare, so equality is one memcmp. A float/double element compares element-wise with IEEE ==, so -0.0 equals +0.0 and NaN is unequal to itself — outcomes a byte compare would get wrong. A value-class element (a String-family value, DynamicString, …) and every object-array element compare element-wise through the element's own ->equals(), with two EMPTY object slots equal and an EMPTY-versus-present pair unequal. Because a class or value-class element is compared through ->equals(), its type must implement Equatable. A T[]->equals() whose element type is a non-Equatable class is a compile error (E5026); a primitive element carries no such requirement.
Example.
int32[] a = [1, 2, 3]
int32[] b = [1, 2, 3]
boolean same = a->equals(b) // TRUE — one memcmp (int32 is uniquely represented)
String[] xs := csv->split(",")
String[] ys := csv->split(",")
boolean eqs = xs->equals(ys) // element-wise String->equals(); String IS Equatable
// Widget[] w // Widget lacks IMPLEMENTS Equatable →
// w->equals(other) // ERROR E5026
Rationale. One equals on the shared surface gives every array a value-equality test whose lowering the compiler picks: memcmp where byte-equality is value-equality, IEEE == for floats, ->equals() for elements that define their own equality. The reader writes one call and the machinery stays inside the kernel — the I.A "relocate the cost inward" tie-break applied to array comparison. Requiring Equatable for a class element is the same "world-class or diagnosed" stance the Set element (E2114) and Dictionary key (E2037) already take.
(f) Rule. On the growable forms — the unbounded T[] and the pre-sized T[N+] — indexed write x[i] = v observes a high-water mark rule. The write always proceeds: capacity grows to admit i if it must, and length := max(length, i + 1). capacity is an allocation hint — what CREATE(n) reserves up front to avoid reallocation — never a bound on what may be written, so no index is "past capacity" on a growable form. Slots between the previous length and i, exclusive of i itself, are zero-filled. The zero for a primitive is its numeric zero, for a String the empty string, and for a struct the all-field-zero default initialization. The same rule covers append: writing x[length] = v extends length by one, with no intermediate slots to fill. The fixed-size T[N] of (h) is exempt. All N slots are live from construction (length == N), so it is indexed and mutated directly, with no high-water-mark bookkeeping and no append.
Example.
int32[] xs := CREATE(8) // capacity 8, length 0
xs[0] = 10 // length → 1
xs[xs.length] = 20 // append idiom — writes at index == length, length → 2
xs[5] = 99 // length → 6; slots 2,3,4 zero-filled (numeric 0)
xs[100] = 7 // capacity grows; length → 101; slots 6..99 zero-filled
Rationale. The high-water-mark rule gives a single, predictable meaning to every write on a growable array and makes x[length] = v the append primitive on a type that has no append method. The rule is deliberately blind to capacity: a bound that came from the argument to CREATE would make the same write succeed or panic depending on a performance hint, which is not something a reader can reason about locally. The fixed form is where a real bound lives — see (h), where N is part of the type and an index at or past it traps.
(g) Rule. A write that would push (length + 1) past 0.79 × capacity triggers growth before it proceeds. The new capacity is capacity + max(capacity ÷ 2, 8), with integer arithmetic, except from a capacity of zero, which seeds at 4 — the general form would yield 8 there and put every later capacity on a different trajectory. Growth repeats until the post-write length sits at or under the threshold. A zero capacity always triggers growth. Construction is lazy: an unbounded T[] starts at capacity 0 and allocates on its first write. The schedule from a default-constructed array runs 0 → 4 → 12 → 20 → 30 → 45 → 67 → 100 → 150 → 225 → 337 → 505 → …, with the growths landing on the writes at index 0, 3, 9, 15, 23, 35, 52, 79, 118, 177 and 237. A pre-sized T[N+] array starts at capacity N and follows the same rule thereafter — T[8+]: 8 → 16 → 24 → ….
The 79% threshold is computed in two regimes, because one arithmetic form cannot serve both ends of the range. At capacity ≤ 225 it is the tabulated exact value floor(0.79 × capacity) — 3, 9, 15, 23, 35, 52, 79, 118, 177. Above it the threshold is (capacity ÷ 100) × 79, divide-first: the multiply-first form overflows a signed 32-bit integer once capacity exceeds 27,183,338, and an overflow PANICs (I.F.ii(a.iv)), so a large array would trap. Dividing first makes the product unreachable at the cost of a coarser threshold, which is why the growth at capacity 337 lands on the write at index 237 rather than 266. Below capacity 100 the divide-first form yields zero, which is precisely why the small regime is tabulated rather than computed.
An explicit reservation is exempt from the threshold. CREATE(N) and reserveExact(n) set the capacity to N + 1 and set the growth threshold to the whole capacity, so writes to indices 0 … N never reallocate and the first write past the reserved range resumes the ordinary schedule. reserve(n) is the other half of the pair and carries the opposite contract: it is an incremental hint for an array still growing, so it leaves the 79% trigger in force and the fill overshoots to roughly 1.5n on its way through — which is what makes a builder that calls it with the running total on every append amortized O(1). The two share a word and nothing else, and collapsing them into one method makes such a builder quadratic. The 79% trigger is a policy for an array growing on its own, where the next size is a guess; a reservation is not a guess, and applying a growth heuristic on top of a stated size would discard the statement. The spare slot is append headroom, so "build N, then append one" is free. The threshold may equal the capacity but never exceed it — a threshold above the allocation would admit a write the buffer cannot hold.
Example.
int32[] xs := CREATE() // capacity 0 — lazy; the first write allocates 4
FOR int32 i = 0; i < 5; i = i + 1 {
xs[xs.length] = i // the append at index 3 crosses 0.79 × 4 and grows 4 → 12
}
int32[] ys := CREATE(100) // capacity 101, threshold 101 — a stated size, not a guess
FOR int32 i = 0; i < 100; i = i + 1 {
ys[i] = i // no reallocation; the schedule is not consulted
}
ys[100] = 7 // still fits — the spare slot is append headroom
ys[101] = 8 // past the reservation: the ordinary schedule resumes
Rationale. Small arrays grow by a minimum of eight slots, so a hot-path append loop does not pay for many small reallocations. Larger arrays grow by 50%, so the amortised cost of repeated growth stays predictable. A reservation is exempt because it carries information the schedule does not have: the caller already knows the final size, and a heuristic applied on top of that can only be wrong. Before the exemption a caller who reserved exactly 100 and wrote 100 elements still reallocated — at element 80 — and reserving more did not help until 200, because the divide-first threshold is constant across capacities 100 through 199. Honouring an exact request should not require a 2× overshoot.
Construction and CONSTANT
(h) Rule. The unbounded value-array, T[] x, is constructed through CREATE() — equivalent to CREATE(4) — or CREATE(N) for an explicit starting capacity. The fixed-size form, T[N] x, is constructed implicitly. The declaration allocates N live, default-initialized slots: length is N from the outset, every slot is immediately readable and writable, and no CREATE call is required. It never grows: N is fixed and an index at or past N is IndexOutOfBoundsError. Because a fixed array never relocates its storage, each element is addressable in place. arr[i] is a mutable lvalue: arr[i] = v writes a whole element, arr[i].field = v mutates a STRUCT or VALUE CLASS element's field directly, and MUTABLE REFERENCE T e =@ arr[i] binds a mutable borrow into the buffer. A bare REFERENCE binds a read-only borrow. This in-place mutability is the fixed form's alone; the growable forms present each element through a write proxy and follow the high-water-mark rule of (f) instead.
Example.
int32[] xs := CREATE(16) // growable — starting capacity 16, length 0
String[] ys := CREATE() // growable — starting capacity 4, length 0
uint64[8] hash_state // fixed — 8 live slots, length 8, no CREATE call
hash_state[3] = 1 // in-place write to an existing slot
hash_state[8] = 1 // ERROR — IndexOutOfBoundsError: index at/past N
Point[4] quad // fixed array of a value class — 4 live slots
quad[0].x = 5 // in-place field mutation of an element
MUTABLE REFERENCE Point p =@ quad[1] // mutable borrow into the buffer
p.y = 9 // writes through to quad[1]
Rationale. The unbounded form is a heap buffer that must be sized, so it takes a CREATE. The fixed form is a known-size allocation the declaration itself pins down, so requiring a CREATE would be ceremony. Its slots are live from the start: a fixed array is its N elements, not an empty buffer to fill. Because its size is fixed and its storage never moves, it can safely hand out a genuine mutable reference to any slot — the basis of arr[i].field = v — where a growable array, which may relocate on the next write, cannot.
(i) Rule. A CONSTANT T[] field, like the primitive CONSTANT of I.K.i, takes a literal array initialiser. The compiler infers capacity from the literal's element count, length equals capacity, and every slot is read-only thereafter. CONSTANT T[N] is the explicit form: the literal must contain exactly N elements or the compiler fires E1084. A CONSTANT value-array is only legal for blittable T; the form lowers to a static constexpr storage that lives in the program's read-only data and never re-allocates.
Example.
PRIVATE CONSTANT uint64[] POW10 = [10, 100, 1000, 10000, 100000,
1000000, 10000000, 100000000,
1000000000] // capacity 9, inferred
PRIVATE CONSTANT uint64[9] POW10_EXPLICIT = [10, 100, 1000, 10000, 100000,
1000000, 10000000, 100000000,
1000000000] // same shape, explicit N
PRIVATE CONSTANT uint64[9] WRONG = [10, 100] // ERROR — E1084: literal ≠ N
Rationale. Inferring capacity from the literal keeps the common case terse; the explicit-N form lets a declaration assert its expected length, and E1084 catches a miscount. Restricting CONSTANT value-arrays to blittable T is what makes the static constexpr lowering into read-only data possible.
(j) Rule. Array literals are written with square brackets, and all elements share one type; a mixed-type literal is a compile error. EMPTY initialises a zero-length value-array, capacity zero for the unbounded form. A statically-detectable out-of-bounds access is a compile error; a runtime one panics with IndexOutOfBoundsError.
Example.
int32[] a = [2, 3, 5, 7] // all elements one type
int32[] b = [1, "two", 3] // ERROR — mixed-type literal
int32[] c = EMPTY // zero-length value-array, capacity 0
int32 x = a[9] // ERROR — statically-detectable out-of-bounds
Rationale. One element type per literal keeps the array homogeneous, under bounded genericity. Catching an out-of-bounds access at compile time, when it is statically knowable, moves the failure as early as possible; it defers to the runtime IndexOutOfBoundsError only when it cannot.
(k) Rule. One element type is rejected outright: boolean[] is a compile error, diagnostic E2083, because the runtime's element-access machinery is incompatible with the proxy-reference representation a boolean vector would need. Boolean data is stored instead as uint8[] with 0 and 1 values, and the restriction extends transitively to the collections that compose on the array. The same rule applies to Array[boolean].
Example.
boolean[] flags // ERROR — E2083
Array[boolean] more // ERROR — E2083 (same rule on the collection)
uint8[] flags = [0, 1, 1, 0] // store boolean data as uint8 with 0/1
Rationale. A packed boolean vector would need a proxy-reference element the array's uniform element-access machinery cannot represent. Rather than special-case it, the language points at uint8[], which stores the same information with the ordinary machinery.
Pre-sized growable arrays
(l) Rule. A third value-array form, T[N+], declares a pre-sized growable array: one whose backing begins at a capacity of N and grows from there on the same schedule as T[]. The + is mandatory, and is what distinguishes it from the never-growing fixed-capacity T[N] — with the +, N is a starting size; without it, N is the whole array and the array cannot grow. The dimension N is either an integer literal or a previously-declared CONSTANT, and must lie in [4, 65536]. Multi-dim chaining is not legal in this form — T[N+][M] is a compile error. The element type T must be blittable, the same constraint as the other value-array forms.
Example.
PRIVATE char32[128+] codepoints // starts at 128 codepoints, grows as needed
PRIVATE char8[512+] bytes // starts at 512 bytes, grows as needed
PRIVATE Vec3[CAP+] recent // starts at CAP positions (CAP a CONSTANT)
PRIVATE char32[128][4+] grid // ERROR — T[N+][M] chaining not legal
Rationale. The + is the visible tell that separates a growable array from the never-growing T[N]. Naming the starting size is the developer saying what they expect to hold, so a build of roughly known length performs one allocation instead of walking the growth schedule from the seed.
(m) Rule. The surface is identical to T[]: .length and .capacity reads, indexed [ ] with the high-water-mark rule, clear(), and iterator(). The only difference is the capacity the array starts at, and that difference is not observable through the surface — nothing reports it but .capacity, and nothing depends on it but the number of reallocations a build performs.
(n) Rule. The starting size is honoured only where the declaration owns the storage: a local, or a class data field. At a parameter, a return type, or a REFERENCE binding the array is supplied by the caller and already has a capacity, so T[N+] there means exactly T[] and the N is ignored.
Rationale. A starting size is a statement about an allocation, and those positions do not allocate. Reading N as a constraint on what the caller may pass would make the two spellings different types for no gain; reading it as advice would make it silently inert. It is a declaration-site hint, so it applies at declaration sites.
Note (2026-08-22). This form was T[N+] for small-buffer optimization until the SBO backing was removed: storage for up to N elements lived inline in the enclosing struct and spilled to the heap beyond it. The measured win did not survive the removal of the inline path — the benchmarks moved by well under a percent — and the split ownership of the growth threshold between the inline and heap regimes was the defect that kept SBO from ever engaging (see I.J.i(g)). The spelling is kept because a starting size is worth naming; the inline storage is not.
Dense N-dimensional arrays
(o) Rule. Beyond the one-dimensional forms above, Envzn provides a dense N-dimensional array as a first-class native type — as native as T[N], and distinct from chaining brackets. Three surface forms share one underlying shape. The type spine T[,] (rank 2), T[,,] (rank 3), and so on carries only the rank, the number of axes. It is what a parameter, field, or return type names when the extents are not part of the type. The runtime form T[m,n] declares and constructs an array whose extents are the run-time values m and n (a Fortran "automatic array"). The fixed form T[3,4] declares an array whose extents are compile-time constants. A bracket spec must be all-empty (the spine) or all-sized; mixing them — float64[m,] — is E1135. Chained brackets T[N][M] are retired: the comma form T[N,M] is the one spelling for a multi-dimensional array, and using them is E2124. A parametric Foo[K,V][] — a type-argument list followed by the array shorthand — is unaffected.
Example.
METHOD solve(float64[,] grid) RETURNS STATUS { ... } // type spine — rank 2, extents not in the type
float64[m,n] board // runtime form — extents are expressions
float64[3,4] fixed // fixed form — compile-time constant extents
float64[m,] bad // ERROR — E1135: mixing empty and sized
float64[3][4] chained // ERROR — E2124: chained brackets retired
Foo[K,V][] ok // fine — type-arg list + array shorthand
Rationale. The comma inside the brackets is the N-D tell. Retiring chained brackets leaves exactly one spelling for a multi-dimensional array.
Because the element type may be an object (p), the comma is not by itself decisive: Foo[A,B] is token-identical to a parametric instantiation. The rule is that a slot shape a type-argument list could never hold settles it as N-D at once: an empty slot (Foo[,]), an integer literal (Foo[3,4]), or any other expression (Foo[n+1,m]). An all-identifier list (Foo[m,n]) is settled after name resolution on the head, since a type that takes no type parameters cannot be instantiated — so String[m,n] has exactly one available reading. This is the same deferral the single-bracket T[X] form already uses, where X is resolved to a class-scope CONSTANT before the fixed-array reading is chosen. Extents are values; type arguments are types. The grammar records the rule in Article III.
(p) Rule. The element type may be a primitive (the blittable numeric path: float64, float32, the integer widths) or an object — a String, a CLASS, an INTERFACE, a VALUE CLASS, or a non-blittable STRUCT. The two are the same language surface; they differ only in the storage the compiler selects (see the lowering rule (t)) and in the cell semantics below. The layout is column-major (Fortran / Julia / BLAS order: axis 0 is the contiguous, stride-1 axis), a fixed property of the language, not a per-array choice. Indices are 0-based. The rank is capped at 15. There is no CREATE for the array itself: like T[N] and the number primitive, an N-D array is constructed implicitly by its declaration.
An N-D array does not grow, so it has no analogue of the 1-D high-water-mark append; an object cell is therefore EMPTY at declaration and is filled in place. A cell whose element is handle-stored — a String, a CLASS, an INTERFACE — reads as EMPTY until it is assigned. It is tested with IF a[i, j], exactly like any other bare-type-with-EMPTY value (I.D.iv). A cell whose element is a value-stored STRUCT or VALUE CLASS is default-constructed instead, and so is never EMPTY. A primitive cell is value-initialised (zero) as before.
Example.
float64[m,n] a // implicit construction — no CREATE
// column-major: axis 0 is contiguous/stride-1; loop the first index innermost (see (s), W10094)
String[2,2] names // 4 EMPTY cells — an N-D array does not grow
names[0,0] := "alpha" // fill a cell in place
IF names[0,1] THEN { … } // EMPTY-checked read (I.D.iv)
Rationale. Column-major is fixed so numeric modules can hand the buffer straight to BLAS without a per-array layout question. The rank cap of 15 is high enough that no real array meets it, and low enough that the shape descriptor stays inline. Object elements admit the text-and-object grids the data surface needs — a column of String is a rank-1 case of the same primitive — without a second, parallel array type. The surface is one thing, and the blittable-versus-owning split is relocated inward to the compiler and kernel.
(q) Rule. An element is read and written with a single multi-coordinate subscript a[i, j] — one coordinate per axis. The coordinate count must equal the array's rank (E2122 otherwise). A fixed extent must be positive (E2123). An out-of-range coordinate is a runtime bounds violation. The container surface is the shape-and-bulk minimum: .rank and .count (read-only fields), dim(k) and stride(k) (per-axis), fill(v), reshapeTo(p, q, …) (a descriptor-only re-extent that succeeds only when the new extents have the same total count), clone(), and isContiguous(). Binding one N-D array to another with := is a deep value copy; for an object element that copy clones every cell, and fill(v) likewise clones v into every cell.
An object cell obeys the relocation rule of I.J.i(d) verbatim, on the multi-coordinate subscript. An object element is never implicitly value-copied, so moving one into a cell uses :=, which transfers ownership and empties the source — whether from another cell (a[i,j] := a[k,l]) or from an owned local (a[i,j] := obj). A plain = between object cells is rejected, E5025. Reading an object cell binds a reference (REFERENCE T x =@ a[i,j]) or takes a clone (T x := a[i,j]); a primitive cell additionally permits a value read (T x = a[i,j]). A live cell reference stays valid across reshapeTo, a descriptor-only re-extent that neither reallocates nor moves data. It is invalidated by an assignment to the array as a whole: that replaces the storage the reference points into, so holding a cell reference across it is E6048. This is the same rule, and the same diagnostic, as a live reference into a 1-D collection cell.
Whole-array element-wise arithmetic — b + c, b - c, and b * c, element-wise or Hadamard rather than a matrix product — produces a fresh array over conforming operands. Both must have the same rank, and the compile-time fixed forms must have the same dims, or the operation is E2125. There is no scalar broadcast in V1; stride-0 broadcast is V2. Arithmetic is defined only for a numeric element type: +, -, and * on an N-D array whose element is an object, or any non-numeric type, is E2132. The operation is element-wise, and there is no element-wise + on a String or a class. Walk the cells instead.
Example.
float64[3,4] a
a[0, 0] = 1.0 // one coordinate per axis
a[0] = 1.0 // ERROR — E2122: rank mismatch (rank-2 array, 1 coord)
a[0,1,2] = 1.0 // ERROR — E2122: rank mismatch (rank-2 array, 3 coords)
float64[0,4] z // ERROR — E2123: fixed extent must be positive
int64 r = a.rank // read-only field — the whole extent family is int64 (2026-09-08)
int64 n = a.count // read-only field
int64 d = a->dim(0) // per-axis extent
int64 s = a->stride(1) // per-axis stride
a->fill(0.0)
a->reshapeTo(4, 3) // descriptor-only; same total count; never moves data
float64[3,4] cp := a->clone()
boolean cont = a->isContiguous() // always true for a dense array
float64[3,4] b
float64[3,4] c
float64[3,4] sum := b + c // element-wise; fresh array; := deep-copies
float64[3,4] had := b * c // Hadamard (element-wise), NOT a matrix product
float64[2,4] wrong
float64[3,4] bad := b + wrong // ERROR — E2125: non-conforming dims
Rationale. Evaluation is naive — each operator materialises one temporary, free for a single operation and one extra temporary-plus-pass per additional operator in a compound expression; fused evaluation, reductions, and linear algebra (matrix multiply, decompositions) belong to the numeric module layer (AdvancedMath) that builds on this substrate and owns the performance trade-off.
(s) Rule. The substrate exposes its raw contiguous column-major buffer as a first-class performance seam so a numeric module can hand it straight to a tuned loop or a C-ABI BLAS without copying. The seam exists only for a numeric element type — an owning handle has no meaning across a C ABI — so it is unavailable, by construction, on an object-element array. The fixed form T[3,4] stores its elements inline, with no heap and no pointer. When the element is blittable this makes the fixed-shape N-D array itself blittable, and therefore nestable inside a STRUCT or another array. When the element is an object the fixed form is still inline but not blittable, since it owns its cells, and so is not nestable in a blittable aggregate. The runtime form T[m,n] holds a heap buffer and is never blittable. A cache-hostile loop nest — one whose innermost loop strides a slow (non-contiguous) axis — draws the advisory W10094, which suggests reordering the loops so the contiguous axis runs innermost. The C++ lowering is given in ENVZN_IR_SPEC.md. The emitter names ONE selector — EvNdArray<T, RANK, MODE, Dims…> — and two dispatches ride it: the element's blittability chooses the backing (_NdArray for a blittable element; _NdObjectArray, which clones per cell rather than byte-copying, for an object one), exactly as the 1-D array (t) does; and the Dims… pack chooses the storage (empty ⇒ the dynamic heap form; complete ⇒ the fixed inline form). Fixed-versus-dynamic is a flag, not a separate type.
Example.
float64[3,4] a
STRUCT Tile { float64[2,2] block } // fixed form is blittable → nestable in a STRUCT
FOR int32 j = 0; j < a->dim(1); j = j + 1 { // contiguous axis 0 innermost — cache-friendly
FOR int32 i = 0; i < a->dim(0); i = i + 1 {
a[i, j] = 0.0
}
}
// striding axis 1 in the inner loop instead → advisory W10094
Rationale. Exposing the raw buffer lets a numeric module reach BLAS without a copy; making the fixed form inline/blittable lets it nest in structs and arrays; W10094 nudges the loop order toward the contiguous axis without forbidding the slow shape.
Lowering
(t) Rule. The value-array lowers to a hand-rolled kernel template, _ValueArray<T, INLINE, MODE> — the leading underscore reserves the name from developer use. Copy and assignment perform the deep value-copy; the CONSTANT form lowers to a static constexpr _ValueArrayConst<T, N>. A non-blittable T[] lowers instead to _ObjectArray<T, INLINE, MODE>: identical surface, but copy-construction and copy-assignment clone each owning-handle element (_ev_unique<U> is cloned, _ev_shared<U> shared). The single point of decision is the C++ alias EvArray<T, INLINE = 0, MODE = EvArrayMode::_GROWABLE> = std::conditional_t<ev_is_blittable_v<T>, _ValueArray<T, INLINE, MODE>, _ObjectArray<T, INLINE, MODE>> — clang resolves the backing at instantiation. The three surface forms map onto this one alias. The unbounded T[] → EvArray<T>, heap and growable. The pre-sized T[N+] → EvArray<T> constructed with a starting capacity of N, heap and growable exactly as T[] is — the two differ only in the capacity the array begins at. (Until 2026-08-22 this was EvArray<T, N>, an inline-N buffer that spilled to the heap on overflow; the INLINE template parameter still exists but no longer has an instantiation.) The fixed T[N] → EvArray<T, N, EvArrayMode::_FIXED>, an inline-N buffer of N live slots that never grows, whose operator[] returns a mutable T& — the basis of the fixed form's in-place element and field mutation, sound because a fixed array never relocates. The blittability closure feeds the ev_is_blittable trait emission. A blittable module STRUCT or VALUE CLASS is given template<> struct ev_is_blittable<Mod::S> : std::true_type {} in its own module header, so S[] selects the value-array. A generic STRUCT or VALUE CLASS whose TEMPLATE qualifier forces every type parameter blittable (GIVEN T IS BLITTABLE) is given the partial specialization template<…> struct ev_is_blittable<Mod::G<T…>> : std::true_type {}. No compiler magic is involved: the types live in kernel headers and the emitter never decides the backing itself.
Rationale. Resolving the backing through a std::conditional_t alias at instantiation, rather than a Python-side branch in the emitter, means even a generic parametric T reaches the correct form. The constexpr-friendly shape of _ValueArray is the whole reason the kernel writes the template itself rather than reusing std::vector.
Part I.J.ii — The collection family and the move/copy convention
(a) Rule. Beyond the array, the kernel ships eight collection types, summarized below. Each has both a bracket short form and an OF long form, and the two are identical to the compiler.
| Collection | Short form | Shape |
|---|---|---|
Dictionary |
Dictionary[K, V, H] |
interface; pluggable hasher H; pure-Envzn HIDDEN impls behind it (ChainedHashDictionary unordered; RedBlackTreeDictionary ordered/K Comparable; ProHashDictionary SipHash-128 DoS-resistant); unique keys |
Set |
Set[V, H] |
concrete wrapper over a Dictionary (element is the key); pluggable hasher H; unique values, unordered |
Box |
Box[T] |
single-slot heap indirection, for recursive types |
Queue |
Queue[T] |
first-in, first-out |
Stack |
Stack[T] |
last-in, first-out |
Deque |
Deque[T] |
double-ended queue |
LinkedList |
LinkedList[T] |
doubly-linked list |
SortedList |
SortedList[T] |
insertion-sorted sequence |
Example.
Queue[Token] q // bracket short form
Queue OF Token q2 // OF long form — identical to the compiler
Rationale. The two spellings exist so a declaration can read either way; they compile to the same type.
(b) Rule. Every collection default-initializes to EMPTY, supports FOR IN, and exposes an ->iterator() method. Bracketed collection types compose by nesting directly; the OF form parenthesizes an inner parameterized type.
Example.
Dictionary[String, Array[Token], DefaultHasher[String]] index // bracketed types nest directly
Dictionary OF (String, Array OF Token) idx2 // OF form parenthesizes the inner parameterized type
FOR t IN q { ... } // FOR IN
LOOP t IN q WITH INDEX i { ... } // FOR IN, carrying the position
ReferenceIterator OF T it := q->iterator() // explicit cursor — WHILE-consumed
WHILE it->next() DO { ... }
Rationale. Direct nesting keeps the bracket form compact; the OF form parenthesizes its inner parametric type to keep the grouping unambiguous when written long-hand.
(c) Rule. The Dictionary interface ships three pure-Envzn implementations as of 2026-06-05; the choice is made by which concrete class is CREATEd, since the interface is held uniformly. ChainedHashDictionary is the general-purpose default — a separate-chaining hash table, unordered, O(1) average. RedBlackTreeDictionary is the ordered map — a guaranteed-O(log n) left-leaning red-black tree that iterates in key order and therefore additionally requires K to be Comparable. ProHashDictionary is the hardened table for untrusted keys — keyed by SipHash-2-4-128, caching the full uint128 digest per entry. Hashing for the hash-based impls is supplied through the H (Hasher OF K) type parameter, and the kernel ships a small hasher family: DefaultHasher (FNV-1a, the hasher constructed when none is named), StringHasher, and SipHasher (keyed SipHash; the default inside ProHashDictionary). Set[V, H] wraps a Dictionary, so the same hasher choices apply to it.
Example.
Dictionary[String, int32, DefaultHasher[String]]
a := CREATE ChainedHashDictionary[String, int32, DefaultHasher[String]]() // hold interface, CREATE impl
Dictionary[String, int32, DefaultHasher[String]]
b := CREATE RedBlackTreeDictionary[String, int32, DefaultHasher[String]]() // ordered; K must be Comparable
Dictionary[String, int32, SipHasher[String]]
c := CREATE ProHashDictionary[String, int32, SipHasher[String]]() // DoS-resistant, keyed SipHash
Set[String, StringHasher[String]] s := CREATE Set[String, StringHasher[String]]()
Rationale. Holding the interface uniformly and selecting behaviour at the CREATE site lets a caller swap ordering or DoS-resistance without changing the variable's type. The hasher H slot keeps the hash function pluggable rather than baked in.
(d) Rule. Every method that takes a value to store comes in two named forms. The bare-named form — append, push, enqueue, insert, add, prepend, and the rest — declares its value-to-store as a MOVE parameter (I.I.vi). It moves the value into the collection and poisons the source, so for a class-typed value a later use of the source at the call site is a compile error. The *Copy form — appendCopy, pushCopy, insertCopy, and so on — takes the same value as a plain (borrowed) parameter and stores a deep copy, leaving the source fully valid. The analyzer recognizes the move-on-call behavior from the MOVE marker on the parameter, not from the method's name.
Example.
Queue[Token] q
q->enqueue(tok) // move — tok is consumed; later use of tok is a compile error
q->enqueueCopy(other) // deep copy — other stays valid
// a user-defined collection following the same naming gets the same enforcement,
// because the MOVE marker on the parameter — not the name — drives it
Rationale. For a primitive or a String the two forms are observably identical, since those copy by value regardless. The distinction carries weight for class-typed elements, and stating it through two method names rather than one overloaded one means a reader of the call site can see which happened. Because enforcement keys off the MOVE marker, a user-defined collection that follows the append/appendCopy naming gets the same enforcement automatically.
(e) Rule. An accessor that may legitimately find nothing returns the pipe-XOR shape (T value | STATUS s) described in I.M: a present value on the success branch, a FAILURE status when the collection is empty or the key is absent. That covers dequeue and peek on a queue, pop on a stack, first and last on a sorted list, and lookup on a dictionary. Those accessors are consumed by an IF whose condition is the call, and not assigned directly.
Example.
IF q->dequeue() THEN {
Token next := $= // success — the dequeued value
}
ELSE {
// the queue was empty — $! is the failure message
}
Rationale. The accessors reuse the language's existing failure vocabulary (I.M) rather than inventing one, so "might find nothing" is expressed the same way everywhere and cannot be silently ignored.
(f) Rule. The accessors carry a stronger guarantee than the pipe-XOR shape alone implies: when the success branch fires, the value returned is real, not EMPTY. The mutators that store a value reject an EMPTY class-typed source at entry, with a FAILURE status and before any state change. They are append, prepend, push, enqueue, insert, add, setAt, and their *Copy siblings. A successful peek / pop / at / lookup therefore returns an IS VALID value by construction. The check is gated on the stored type being class-shaped, through the WHEN T IMPLEMENTS Cloneable proxy in the kernel implementations. It is elided for primitive and struct elements, where EMPTY is not a possible state (I.I.iii).
Example.
Queue[Token] q
q->enqueue(tok) // move — tok is consumed
q->enqueueCopy(other) // deep copy — other stays valid
IF q->dequeue() THEN {
Token next := $= // success — guaranteed IS VALID, never EMPTY
}
ELSE {
// the queue was empty
}
Rationale. By refusing an EMPTY class-typed value at the store site, the collection only ever holds populated handles, so a caller who took the success branch never has to re-check for EMPTY. The gate is elided for primitive/struct elements because those cannot be EMPTY (I.I.iii).
(g) Rule. Dictionary keys must implement Equatable and be hashable; Set elements likewise; SortedList elements must implement Comparable. Box[T] is the exception: a single-slot, heap-allocating wrapper whose purpose is to break the type cycle that a recursive GROUP member would otherwise create. A Box[T] field is transparent: reading it yields T directly, and a MATCH sees T. It is never EMPTY, because it always holds a value, so a class with Box fields always provides an INIT that sets them.
Example.
Dictionary[String, int32, DefaultHasher[String]] d // String key: Equatable + hashable — OK
SortedList[int32] sl // int32 element: Comparable — OK
GROUP Tree { Leaf, Node }
STRUCT Node { Box[Tree] left; Box[Tree] right } // Box breaks the recursive type cycle
// reading a Box[Tree] field yields Tree directly; it is never EMPTY, so INIT must set it
Rationale. Keys and elements carry the contracts their collection needs (equality/hashing for hash keys, ordering for sorted elements). Box is the deliberate exception because its only job is to introduce a heap indirection that breaks an otherwise-unresolvable recursive type cycle, so it is transparent and always populated.
The Collection interface — the minimum every container carries.
Collection OF T is the contract that marks a type as a container. It extends
Countable and adds one method of its own:
INTERFACE Collection OF T EXTENDS Countable { // size(), isEmpty()
METHOD iterator() RETURNS ReferenceIterator OF T
}
Three methods — size, isEmpty, iterator — and none of them asks anything
of T. That is the point: membership is open to every container in the
library, so it is a real marker rather than a badge some containers happen to
qualify for. Collection's own qualifier is correspondingly the widest of any
container's.
contains is deliberately not in the minimum. Containment needs equality
on T, and Array[T] requires only Cloneable of its elements; putting
contains in the minimum would force Equatable onto every element type in the
language the moment Array joined. Containment belongs with Searchable OF T,
whose find needs exactly the same equality and of which contains is the
boolean shadow.
Rationale. Iteration is the more primitive notion — contains is derivable
from iterating and comparing, while iteration cannot be derived from containment
— so the minimum is drawn at the irreducible core. This also tracks a real split
among languages: where equality is universal, a collection interface can require
contains; where equality is opt-in, as it is here, it cannot.
Dictionary equality and ordering. A Dictionary is Equatable and is not
Comparable. Two dictionaries are equal when they hold the same keys, each
mapping to an equal value; a hash dictionary has no canonical enumeration and
therefore no well-defined order, so none is offered (see I.K.iv).
Part I.J.iii — Iteration
(a) Rule. Every collection hands out a fresh iterator from ->iterator(), and each call yields a new, independent cursor. Two iterators over the same collection share no state, and traversal never mutates the collection it walks. There are two iterator interfaces. Iterator OF T is forward-only, with hasNext, next, peek, and skip. BidirectionalIterator OF T extends it with reverse traversal — hasPrevious, previous, skipBack. The navigation methods return a non-owning handle into the source collection, in the pipe-XOR shape (REFERENCE T t | STATUS s). That is a reference to the element on the success branch, and a FAILURE status when the cursor has run past the end.
Example.
Iterator OF Token it := q->iterator()
IF it->next() THEN {
REFERENCE Token t =@ $= // non-owning handle into the source collection
Token owned := t->clone() // a caller who needs an owned copy clones explicitly
}
ELSE {
// cursor ran past the end
}
Rationale. The handle is non-owning by design, because iteration does not clone. A caller needing an owned copy writes ->clone() explicitly inside the validated branch, which keeps the default traversal allocation-free.
(b) Rule. Iteration imposes a mutation lock. Holding an iterator on a collection locks that collection against mutation for the scope in which the iterator lives. That applies whether the iterator is bound explicitly from ->iterator() or implicitly through a FOR IN loop. Two operations on a locked collection are compile errors: calling any of its mutating methods, and rebinding the variable that names it. The lock is a compile-time analysis with no runtime cost, it propagates into nested blocks, and it lifts when the declaring scope ends. FOR IN over any collection, and over any user-defined class implementing Iterator OF T, reaches its body through the same ->iterator(), hasNext(), next() sequence. The loop bakes in no per-collection knowledge; its exact lowering is given in ENVZN_IR_SPEC.md C.iii.
Example.
FOR t IN q {
q->enqueue(other) // ERROR — mutating a collection locked by iteration
q := makeAnother() // ERROR — rebinding the iterated variable
}
{ // anonymous block bounds the lock precisely
Iterator OF Token it := q->iterator()
// ... q is locked here ...
} // lock lifts when this scope ends
q->enqueue(other) // OK — the iterator's scope has ended
Rationale. Forbidding mutation and rebinding under an active cursor rules out iterator invalidation at compile time, with no runtime cost. Because the lock is scoped, the anonymous block of I.G is a useful tool for bounding an iterator's lock precisely.
(c) Rule. Beyond explicit iteration, Array[T] carries the higher-order traversals that consume a lambda (I.F.v). ->map(LAMBDA transform) produces a new Array of the transformed elements; ->filter(LAMBDA predicate) produces a new Array of the elements the predicate keeps. ->reduce(seed, LAMBDA combine) folds the elements into a single accumulated value, threading the accumulator from seed through each element in order. Each applies its non-escaping lambda to the elements without mutating the source, and each holds the same iteration mutation-lock for its duration. All three are ELEMENT-TYPE-PRESERVING: map on an Array[T] returns an Array[T], and reduce folds to a T. There is no Array[T] → Array[U] map, and that is a decision rather than a gap (ruled 2026-08-13) — deriving U from a lambda's return type means inferring a second type parameter out of a lambda body, which is the template-metaprogramming direction that bounded genericity (I.A) exists to keep shut. A transformation that changes type is written as an explicit loop, where the reader can see the target type. Status: shipped in V1 (2026-06-26). map, filter, and reduce are live on Array[T] as pure-Envzn loops over an invoked lambda, exercised end-to-end by lambdaSmoke.
Example.
Array[int32] nums
Array[int32] doubled := nums->map(LAMBDA x: x * 2)
Array[int32] evens := nums->filter(LAMBDA x: x % 2 == 0)
int32 total := nums->reduce(0, LAMBDA (acc, x): acc + x) // seeded fold — 0 + every element
Rationale. The shape given here is the intended one recorded ahead of the implementation (I.A); each traversal applies a non-escaping lambda without mutating the source and holds the mutation-lock for its duration, matching explicit iteration's guarantees.
Part I.J.iv — STRUCT
(a) Rule. A STRUCT is a pure-data record. Like a class it is heap-allocated with a stack handle, but far more constrained. All of its fields are public, each field is a primitive or a String, it has no methods, and it neither extends nor implements anything. It has no INIT — the compiler generates construction directly from the field declarations in order, and construction is positional, in declaration order. A STRUCT field may be marked VOLATILE. Assignment of a struct follows the class rules, with := taking a deep copy.
Example.
STRUCT Point { int32 x; int32 y }
Point p := CREATE Point(3, 4) // positional — compiler-generated construction
Point r := p // := takes a deep copy (class assignment rules)
STRUCT Counter { VOLATILE int32 ticks } // a STRUCT field may be VOLATILE
Rationale. The struct exists for the case where a value is genuinely just a bundle of named fields, and any method would be a method on something else. The constraints — all-public, primitive or String fields, no methods, no inheritance — keep it a pure record, so the compiler can generate its construction directly.
Part I.J.v — GROUP
(a) Rule. A GROUP declares a named type classifier — a set of types that share a conceptual category. A member is a primitive, a class, a struct, an interface, or another GROUP; a name that classifies no value — a NAMESPACE, an ENUM — is not a member (E6081), and a member that resolves to no type at all is E6080. A member naming a parametric class may omit its type arguments (Array, not Array[T]): the bare head is the classifier, so every instantiation of it is a member, including instantiations that do not exist yet. Membership is transitive — a type belonging to a member group belongs to the enclosing group — while the written members remain members in their own right; group→group membership may not form a cycle (E6082). A group has no fields and no methods. A group body must list at least one member, and CREATE on a group is a compile error since there is nothing to construct. A group member containing a field of the group's own type must declare that field Box[GroupType], to break the otherwise-unresolvable type cycle.
(a.i) Rule. A group is a classifier always and a storage type only when every member has a single concrete representation. A group all of whose members are primitives, classes, structs or interfaces may type a variable, parameter, return or field: it holds exactly one member value at runtime, stored as a heterogeneous variant, and MATCH unwraps it to the concrete type. A group with a GROUP member or a bare parametric head has no such representation and may not type a value (E6083); it still classifies, so it remains legal as a template-qualifier atom and in a WHEN … IS on a type parameter.
Example.
GROUP Shape { Circle, Rectangle, Triangle }
MATCH currentShape {
WHEN Circle c: { ... } // MATCH unwraps to the concrete member type
WHEN Rectangle r: { ... }
WHEN Triangle t: { ... }
}
Shape s := CREATE Shape(...) // ERROR — CREATE on a group: nothing to construct
GROUP Empty { } // ERROR — a group body must list at least one member
Rationale. A group is a closed classifier over concrete types, so it needs no construction of its own — each variable holds one member value — and MATCH is the way to recover the concrete type. A self-typed member field must be Boxed for the same cycle-breaking reason as I.J.ii(g).
(b) Rule. The kernel provides one built-in group, Numeric, globally available without declaration. Its members are enumerated, not the whole numeric primitive family: int8 int16 int32 int64 int128, uint8 uint16 uint32 uint64 uint128, float32 float64. char is not a member: a code point admits an offset and a distance, I.D.i(c.ii), and nothing else; and binary is not, because a bit pattern is not a number. No octet type is numeric: the group once admitted byte on the grounds that it was "structurally an unsigned 8-bit integer", which was true of its storage and false of its meaning, and is why that spelling was retired in favour of uint8 where a number was meant. The kernel's declaration in interfaces.ev is the authority for this set. The four arithmetic operators, and the equality and ordering comparisons, are defined on a Numeric value and dispatch to the concrete type at runtime. Any other method call on a group-typed value is a compile error. User-defined groups are declared in a module's interfaces.ev and are module-scoped.
Example.
Numeric n // built-in group, globally available, no declaration
// + - * / and == / < are defined on a Numeric value (dispatch to the concrete type)
// any other method call on a group-typed value is a compile error
Rationale. Numeric is built in because it is the one classifier every program shares. Restricting the callable surface to the arithmetic and comparison operators keeps a group a classifier rather than an interface; anything richer is expressed with a MATCH to the concrete type.
Part I.J.vi — VALUE CLASS
(a) Rule. A VALUE CLASS is the third type-kind, sitting between the STRUCT and the CLASS. It carries the methods, generics, and interface conformance of a class, but the value identity and inline storage of a struct. Declared VALUE CLASS Name[T,…] IMPLEMENTS I { … } (with the optional TEMPLATE: GIVEN … preamble), it is reached by value rather than through a heap handle — stored inline wherever it lives, passed by value, and copied as a whole value. Its data fields are read with .; its methods are still invoked with -> (the value-class exception to "-> sends a message to an instance", shared with number/complex).
Example.
VALUE CLASS Vec3 IMPLEMENTS Equatable {
PRIVATE float64 x
PRIVATE float64 y
PRIVATE float64 z
METHOD magnitude() RETURNS float64 { ... }
}
Vec3 v := CREATE Vec3(1.0, 2.0, 3.0) // stored inline, no heap handle; bind with :=
float64 m = v->magnitude() // method reached with ->
Rationale. It exists for the case where a value is genuinely a small bundle that also behaves: a collision-node, a point, a fixed-size record. There a struct's "no methods" is too strict, and a class's heap identity too heavy. Data-with-., methods-with--> keeps the value-class consistent with number/complex.
(b) Rule. A value class is strict when all of its fields are themselves value-typed — a primitive, an enum, a STRUCT, or another value class. It is then trivially copyable and stored as a contiguous value. It is managed when a field owns heap through a class-typed handle, and the compiler then synthesizes its copy constructor, so that copying deep-clones the owned content. This distinction is derived from the field types, never declared, and is invisible at the surface. Because a value class is still a class, every owning bind uses := — construct, clone, or move. The value operator = of I.F.iv, reserved for primitives, STATUS, and enums, is a compile error on a value class.
Example.
VALUE CLASS Point2 { PRIVATE float64 x; PRIVATE float64 y } // strict — all value-typed fields
VALUE CLASS Owner { PRIVATE Buffer buf } // managed — owns heap; copy ctor synthesized
Point2 p := CREATE Point2(1.0, 2.0) // := whether strict or managed — always
Point2 q := p // ERROR would be `q = p` — E3023: `=` is a compile error here
Rationale. A developer therefore never has to inspect a value class's field composition to choose the assignment operator: it is always :=. The strict-vs-managed split is a compiler concern (whether to synthesize a deep-copy constructor), invisible at the surface.
(c) Rule. A value class is an inheritance leaf: it may IMPLEMENTS interfaces but may neither EXTENDS another type nor be extended (E1132), value layout having no vtable and no base-slicing model. It satisfies an interface for the purpose of a generic type constraint — a Dictionary's hasher, an Equatable key, a Cloneable element — resolved statically at the instantiation site; storing a value class behind a polymorphic interface-typed handle — existential boxing — is reserved for V2 and is a compile error in V1 (E1133). The remedy is to hold the concrete value type, or to use an object CLASS where interface polymorphism is required. A VALUE modifier may not combine with ABSTRACT or SINGLETON and must precede CLASS.
Example.
VALUE CLASS Vec3 EXTENDS Base { ... } // ERROR — E1132: a value class is an inheritance leaf
VALUE CLASS Vec3 IMPLEMENTS Equatable { ... } // OK — IMPLEMENTS satisfies a generic constraint
Equatable e := someVec3 // ERROR — E1133: existential boxing reserved for V2
VALUE ABSTRACT CLASS Bad { ... } // ERROR — VALUE may not combine with ABSTRACT/SINGLETON
Rationale. Value layout has no vtable and no base-slicing model, so inheritance (E1132) and existential boxing (E1133) are refused in V1. The value class still satisfies interfaces for statically-resolved generic constraints, which is enough for hashers, keys, and cloneable elements. Where interface polymorphism is genuinely needed, the remedy is an object CLASS.
(d) Rule. Status: shipped in V1 (2026-06-24). The construct generalizes the mechanism that previously expressed value-with-behaviour in the kernel: the compiler-known number and complex value-classes (I.D.i(g)), which remain primitive-backed. DynamicString and DynamicByteBuffer were hand-recognized special cases, then ordinary VALUE CLASS declarations, and are ordinary heap classes as of 2026-08-24 — a value class is value identity plus inline storage, and once the SBO backing was removed they had neither. A consuming module re-derives a dependency's value semantics from the VALUE modifier carried in its .headers interface file. An imported value class therefore lowers to inline storage exactly as a local one does.
Rationale. Making VALUE CLASS a first-class kind let the kernel's value-with-behaviour types be ordinary declarations rather than hand-recognized special cases, while number and complex stay primitive-backed. Propagating the VALUE modifier through the .headers file means a consumer lowers an imported value class to inline storage exactly as a local one does.
Section I.K — Abstractions
The class and everything arranged around it: interfaces, abstract classes, singletons, and namespaces; inheritance and FINAL; operator interfaces and Cloneable; construction; methods, OVERRIDE, and MODIFY; polymorphic collections; enumerations; and the template qualifier.
(a) Rule. The class is the primary unit of abstraction in Envzn. Around it sit the interfaces, enumerations, and inheritance rules that let a program describe behaviour in terms of contracts rather than concrete types, and this section gathers all of them. The field declaration form is given in I.E, and the ownership a field implies in I.I; what follows describes the class as a whole and the constructs that relate classes to one another.
Rationale. Gathering the relating constructs in one Section keeps the class readable as a whole. A reader learning what a class is should not have to assemble it from the declaration rules of I.E, the ownership rules of I.I, and the abstraction rules here at once.
Part I.K.i — Classes
(a) Rule. A class instance is heap-allocated and reached through a stack handle. A class's data fields are public by default, and are mutated through MODIFY methods (I.K.vi). A developer narrows or fixes them deliberately. PRIVATE, PROTECTED, and INTERNAL restrict the audience; SHARED exposes a field to subclasses for reading, and CONSTANT declares a compile-time-fixed field under the rules of I.E.
Example.
CLASS Account {
PRIVATE int64 balance // narrowed to Account — mutated only via MODIFY methods
SHARED String owner // subclasses may read this field
CONSTANT int32 MAX_HOLDS = 5 // compile-time-fixed
}
Rationale. Naming the audience only where it is restricted, and gating mutation behind MODIFY, makes every narrowing of visibility and every write path an explicit, auditable choice rather than an accident.
(a.i) Rule. Envzn permits no anonymous types, and a CLASS, INTERFACE, or GROUP may only appear at the top level of a module — nesting one inside a class body is diagnostic E6028.
Example.
CLASS Widget {
CLASS Helper { ... } // ERROR E6028 — a CLASS may not nest inside a class body
INTERFACE Drawable { ... } // ERROR E6028 — nor an INTERFACE
GROUP Numbers { ... } // ERROR E6028 — nor a GROUP
}
Rationale. A type worth sharing is a type worth naming and locating by name at the module's top level; anonymous or nested types hide behavior where a reader cannot find it.
(a.ii) Rule. The two exceptions are ENUM and STRUCT, which a class may declare in its body as implementation-detail types provided they are marked PRIVATE. A bare ENUM or STRUCT inside a class is diagnostic E6039, with a fix-it pointing either to adding PRIVATE or to moving the declaration into the module's enums.ev or structs.ev. A class-nested PRIVATE ENUM or PRIVATE STRUCT is scoped to its declaring class: the name is unreachable outside it, and the symbol is not hoisted into the module-flat enum or struct namespace. A declaration never referenced inside the owning class draws the warning W10050.
Example.
CLASS TrafficSignal {
PRIVATE ENUM Light { RED YELLOW GREEN } // OK — scoped to TrafficSignal, unreachable outside
PRIVATE STRUCT Timing { int32 red, green } // OK — packed value-record private to the class
ENUM Mode { AUTO MANUAL } // ERROR E6039 — bare nested ENUM; add PRIVATE or move to enums.ev
}
Rationale. The construct serves the case where a class wants a closed set of states, or a packed value-record of its own. Requiring PRIVATE and scoping the name to the class keeps a type of broader audience where it belongs: at the top level of the module, named and locatable.
(b) Rule. The access modifiers that govern a class member are three, each narrowing from a public default: PRIVATE confines a member to its declaring class; INTERNAL widens that to every class in the same module while keeping the member invisible outside it; PROTECTED confines it to the declaring class and its subclasses. (SHARED, or SHARED VOLATILE, exposes a field to the declaring class and its subclasses.) A member that declares none of them — a field, a CONSTANT, or a method — is public, reachable by anyone with access to the class. Public is therefore the space a declaration occupies by saying nothing, and it has no keyword of its own: PUBLIC is reserved but unrecognized, and writing it is a parse error. The Appendix records that reservation. The module-level consequences of INTERNAL, and the rule that it may not be applied to an interface or an abstract class, are described with the module system in I.N.
Example.
CLASS Ledger {
PRIVATE int64 secret // Ledger only
INTERNAL int32 auditTag // every class in this module, invisible outside
SHARED String journal // Ledger and its subclasses may read
int32 openingYear // anyone — no modifier is the public default
METHOD total() RETURNS int64 { ... } // anyone — likewise
}
Rationale. Three modifiers name three concentric audiences inside a public default, so the reach of a member is stated at its declaration exactly where that reach is restricted. A fourth keyword for the default would be a word that changes nothing: every declaration would carry a modifier, and the ones that actually restrict would stop standing out.
Part I.K.ii — Interfaces, abstract classes, singletons, and namespaces
(a) Rule. An INTERFACE declares a contract: a set of full method signatures with no bodies and no data fields. An interface may extend one or more other interfaces, the parents separated by commas, and a class that implements the child must satisfy every method of every ancestor. An interface never uses IMPLEMENTS — it is a contract, so it has nothing to implement, and composing contracts is what EXTENDS is for (E6090). Where two extended interfaces declare the same method with the same parameter types and an incompatible result, the child is rejected (E6091): no call site could choose between them. The same method reached by two paths with an identical signature is one contract, not a conflict, and is kept once; a shared name with different parameters is an ordinary overload. A class adopts an interface with IMPLEMENTS, and leaving any declared method unimplemented is a compile error. An interface with no methods at all is itself an error — the construct for "a set of related types" is GROUP, not an empty interface.
Example.
INTERFACE Drawable {
METHOD draw(int32 x, int32 y) RETURNS VOID
}
INTERFACE Shape EXTENDS Drawable, Measurable { ... } // two parents, comma-separated
CLASS Circle IMPLEMENTS Drawable {
METHOD draw(int32 x, int32 y) RETURNS VOID { ... } // must satisfy every ancestor method
}
INTERFACE Empty { } // ERROR — an interface with no methods; use GROUP instead
Rationale. An interface is behavior with no state, so it carries signatures only; the empty-interface error steers "a set of related types" toward GROUP, the construct actually built for it.
(b) Rule. An ABSTRACT CLASS is a partial class. A method declared ABSTRACT METHOD has no body and is an abstract method — a subclass must implement it; a method declared with a body is concrete and inherited as written. An abstract class must declare at least one ABSTRACT METHOD, since a class with no unfilled slots is a regular CLASS (E6073). An ABSTRACT METHOD may appear only inside an abstract class (E6074). Neither an abstract method nor an abstract class may be PRIVATE — an abstract member is a contract its subclasses must implement, and PRIVATE would hide it from the very types meant to fulfil it (E6077); MODIFY composes freely, so ABSTRACT MODIFY METHOD is valid. For every interface it IMPLEMENTS it must declare a matching method, concrete or ABSTRACT METHOD, never omitting it (E6020), exactly as a concrete class would. An abstract class cannot be instantiated directly (E6029), and a class that extends one must implement every abstract method or remain abstract itself (E6006). It may EXTENDS one class and IMPLEMENTS several interfaces like any class, but it may not be a generic template (E6075).
Example.
ABSTRACT CLASS Conveyance {
ABSTRACT METHOD move() RETURNS VOID // abstract — no body; the subclass implements it
METHOD describe() RETURNS String { RETURN ("a conveyance") } // concrete — inherited as written
}
CLASS Aircraft EXTENDS Conveyance {
METHOD move() RETURNS VOID { ... } // implements the abstract method
}
Conveyance c := CREATE Aircraft() // OK — the concrete class is named (I.F.iii)
Conveyance d := CREATE Conveyance() // ERROR E6029 — an abstract class cannot be instantiated directly
Rationale. An abstract class mixes shared implementation with unfilled slots, so it can be neither instantiated nor left with an abstract method unimplemented in a concrete subclass. The explicit ABSTRACT METHOD marker makes a missing body a deliberate declaration the parser and analyzer can act on, not an accident.
(c) Rule. A SINGLETON CLASS is a single-instance actor — a class modelling a process-level service that performs behaviour, of which the kernel's System (and Process, Stdio, DateTimeFactory) are the canonical examples. Singletons are rare: a type is a singleton only when there is genuinely one of it and it acts. The construct is mutually exclusive with NAMESPACE — a stateless grouping of functions is the latter, not the former. Five rules, each with its own diagnostic, hold the construct to its purpose: SINGLETON applies only to CLASS and not to the other type kinds (E1099); in V1 a singleton declares no data fields at all (E1100), and its methods may not be MODIFY (E1101). It may neither extend nor be extended, singletons being leaves of the inheritance tree (E1102), and it may not declare INIT (E1103). A singleton's methods are called with -> (System->exit(...)) — the surface reads as sending a message to the live instance/actor.
Example.
SINGLETON CLASS System {
METHOD exit(int32 code) RETURNS VOID { ... }
}
System->exit(0) // called with -> : a message to the live actor
SINGLETON STRUCT Bad { ... } // ERROR E1099 — SINGLETON applies only to CLASS
SINGLETON CLASS Cache {
PRIVATE int32 hits // ERROR E1100 — V1 singleton declares no data fields
MODIFY METHOD reset() { ... } // ERROR E1101 — a singleton method may not be MODIFY
INIT() { ... } // ERROR E1103 — a singleton may not declare INIT
}
SINGLETON CLASS Sub EXTENDS System { ... } // ERROR E1102 — a singleton neither extends nor is extended
Rationale. A singleton is the rare "one-of, and it acts" case; the five diagnostics keep it from drifting into a stateful object or a mere function bag. Interior state is deferred to V2+ (and when admitted must be thread-safe), which is why the V1 no-fields rule holds.
(d) Rule. A NAMESPACE is a stateless host of free functions — the mirror-opposite of a STRUCT (which holds data and no functions): a NAMESPACE holds functions and only constant data. The two are perfectly complementary: NAMESPACE + STRUCT ≈ CLASS. All a CLASS adds beyond the sum of the two is inheritance (EXTENDS) and interface implementation (IMPLEMENTS), in neither of which a namespace or struct participates. Math (square root, gcd, the numeric functions and constants) is the canonical example; namespaces should be common. NAMESPACE Name { … } admits METHOD declarations at any of the three visibilities, together with CONSTANT data fields and constant arrays. It also admits FOREIGN BIND CONSTANT <type> <NAME> members (added 2026-09-08): a C constant read once, by the bind mechanism of §17, into a member of the namespace — spelled _ev_fbc_<NAME> in the lowering, since the C name is a macro the preprocessor would rewrite — and qualified by the namespace at every use, NumericLimits.INT8_MIN. So a limit keeps the name C gave it, the preprocessor never sees it as a declaration, and the member belongs to its declarer: two namespaces binding the same C name are two members, never a redefinition. This is the one place a FOREIGN declaration may appear below the top of a file. The visibilities are public (the default), PRIVATE (callable only by sibling functions of the same namespace), and INTERNAL (callable anywhere in the same module). A variable (non-CONSTANT) data field is rejected (E1107), a MODIFY method is rejected (E1108), and an INIT or CLEANUP is a parse error (a namespace is stateless, with no instance lifecycle). Because a namespace has no instance, its members are reached with . — Math.PI, Math.sqrt(x) — and calling a namespace method with -> is an error (E1106). The C++/Rust scope-resolution spelling Math::sqrt(x) is likewise an error (E1109): :: qualifies a module (ALIAS mda::TextColumn, I.N.d.iv), never a namespace member, so the two sigils never overlap. Inside the namespace its own members are referenced bare (PI) or with the leading-dot self form (.PI), both resolving to the same in-scope member. A namespace name follows the PascalCase type-name convention (W10026 otherwise).
Example.
NAMESPACE Math {
CONSTANT float64 PI = 3.14159265358979
METHOD sqrt(float64 x) RETURNS float64 { RETURN (.helper(x)) } // .helper — leading-dot self form
PRIVATE METHOD helper(float64 x) RETURNS float64 { RETURN (PI * x) } // PI — bare self reference
}
float64 r := Math.sqrt(2.0) // reached with . — no instance
float64 p = Math.PI
NAMESPACE Counters {
int32 total // ERROR E1107 — a variable (non-CONSTANT) data field
MODIFY METHOD bump() { ... } // ERROR E1108 — a MODIFY method in a namespace
}
Math->sqrt(2.0) // ERROR E1106 — a namespace method is called with . , not ->
Math::sqrt(2.0) // ERROR E1109 — `::` qualifies a MODULE, not a namespace member
Rationale. Behaviour-and-constants (NAMESPACE) plus data-fields (STRUCT) is the substance of a class, so each half stands as a first-class construct. A namespace exists so that not everything need be a class, and its statelessness is what forbids variable fields, MODIFY, and a lifecycle. The kernel's character-classification, codec, and numeric-conversion families are namespaces — CharClassifier, HexCodec, Base64Codec, ByteOrderCodec, NumericUtilities — as are the numeric and formatting hosts Math, FloatFormat, UTFCodec, and the hash-constant tables. The conversion families into which the former Convert singleton was broken up live here too. A NAMESPACE lowers to a C++ namespace Name { … } of inline free functions and static constexpr constants — no backing struct, no instance — and a Name.method(…) call lowers to Name::method(…).
(e) Rule. A SHARED CLASS is an interior-mutable monitor — a class built to be shared by reference across concurrent tasks while holding its own synchronization internally. It is the only class kind a SHARED MUTABLE REFERENCE (I.I.vi) may point at, sound precisely because a monitor serialises its own access. Two rules hold it to that contract. It must IMPLEMENTS Shareable — the marker interface that admits a type into a SHARED MUTABLE REFERENCE field and into the move-only OwnedList[T] collection — and a SHARED CLASS that does not is E2115. And every one of its data fields must be PRIVATE, so the only path to its state is through its synchronized methods and no holder can mutate a field bypassing the lock. An externally-visible field — one left bare, and so public by default, or marked SHARED, PROTECTED, or INTERNAL — is E2116.
Example.
SHARED CLASS Channel[T] IMPLEMENTS Shareable { // canonical SHARED CLASS
PRIVATE Lock lock // every field PRIVATE
PRIVATE Queue[T] buffer
MODIFY METHOD send(T value) RETURNS VOID {
SYNCHRONIZED (.lock) { ... } // author hand-gates the critical section
}
}
SHARED CLASS Bad { ... } // ERROR E2115 — a SHARED CLASS must IMPLEMENTS Shareable
SHARED CLASS Leaky IMPLEMENTS Shareable {
int32 count // ERROR E2116 — public by default; bypasses the lock
}
Rationale. The carve-out that lets multiple holders alias a SHARED CLASS mutably is safe only because the monitor serialises access; the two rules — Shareable and all-private fields — are exactly what guarantee that. The internal synchronization itself is the author's responsibility in V1. A SHARED CLASS typically holds a Lock and wraps its critical sections in SYNCHRONIZED blocks by hand, as Channel does with condition-variable logic that hand-gating cannot replace. The once-reserved AUTO SYNCHRONIZED CLASS form, a compiler-injected per-method SYNCHRONIZED, was declined and retired. It is redundant with — and strictly weaker than — Mutex[T], which preserves the compound-operation escape hatch an auto-monitor removes, and it would require a reentrant lock the kernel does not have. Use Mutex[T], the manual SHARED CLASS monitor, or Channel[T] / CONCURRENT instead.
HIDDEN classes.
A HIDDEN class must declare a non-empty IMPLEMENTS or EXTENDS, and its name
may appear only as a CREATE target. It may not be used as a type — not in a
declaration, a parameter, a return, a template qualifier atom, or a GROUP
member:
Dictionary[K, V, H] d := CREATE ChainedHashDictionary[K, V, H]() // legal
ChainedHashDictionary[K, V, H] d := CREATE ChainedHashDictionary[K, V, H]() // E1131
The restriction spans module boundaries. It binds every consumer of the
class, not only the module that declares it — and the consumers are the
population it exists to protect, since the declaring module's author already
knows. A module's exported header therefore carries the HIDDEN marker, as it
carries FINAL, SHARED, and VALUE.
Two consequences follow for the author of a HIDDEN class, and both are
obligations rather than options:
- Its own
clone()(and any method returning "one of me") must be declared as the interface it implements, never as the concrete class — the concrete name is unwritable in a return type. - Anything a consumer legitimately needs must be on the interface. A method
that exists only on the concrete class is unreachable, and "reachable only by
the declaring module" is the whole meaning of
HIDDEN. Deciding to hide a class is therefore also deciding that its interface is complete.
Part I.K.iii — Inheritance, FINAL, and type narrowing
(a) Rule. A class may extend zero or one class — single inheritance — and may implement zero or more interfaces; an abstract class may do both. FINAL on a class forbids subclassing it, and FINAL on a method forbids overriding it. A subclass may read a parent's data fields only where the parent declared them SHARED, and may freely add its own fields and methods.
Example.
CLASS Shape {
FINAL METHOD identity() RETURNS int32 { RETURN (.id) } // FINAL method — cannot be overridden
}
FINAL CLASS Circle EXTENDS Shape IMPLEMENTS Drawable, Measurable { // one parent, many interfaces
PRIVATE float64 radius // subclass freely adds its own fields
}
CLASS Sphere EXTENDS Circle { ... } // ERROR — Circle is FINAL and cannot be subclassed
Rationale. Single inheritance plus multiple interface implementation gives one line of shared implementation and any number of contracts; FINAL lets an author seal a class or a method against extension where subclassing would break an invariant.
(b) Rule. Envzn permits no casting, ever — a value is never reinterpreted as another type by assertion. Where a program needs to branch on a more specific type it uses a WHEN type-check, never an IF. A WHEN type-check pairs a subject with a predicate: IS names a class, an abstract class, a GROUP, an individual primitive type (e.g. int32, float64), or the PRIMITIVE category; IMPLEMENTS names an interface. The two keywords lower to the same compile-time test, so each must state the named type's kind honestly — IS on an interface, or IMPLEMENTS on a class, is a compile error. An enum or struct is not a valid IS target (E1104): a value typed as an enum or struct has no subtype to narrow to. Dispatch an enum with MATCH on its cases instead. The standalone form requires a mandatory ELSE, with an optional DO before the body.
Example.
WHEN shape IS Circle { shape->radius() } ELSE { … } // class — abstract parents match too
WHEN shape IS NOT Circle { … } ELSE { … } // negated
WHEN shape IMPLEMENTS Drawable { shape->draw() } ELSE { … } // interface
WHEN T IS PRIMITIVE { … } ELSE { … } // type parameter — category test
WHEN T IS Numeric { … } ELSE { … } // type parameter — GROUP membership
WHEN n IS int32 { … } ELSE { … } // individual primitive (type param or concrete-typed value)
WHEN value IS animals::Dog { … } ELSE { … } // a scoped, cross-module type name
WHEN d IS Direction { … } ELSE { … } // ERROR E1104 — an enum is not a valid IS target
Rationale. Forbidding casts and routing every type branch through WHEN means the narrowing is something the compiler proves rather than something the developer asserts (see (d)). Requiring IS/IMPLEMENTS to name the type's kind honestly keeps the check greppable and self-describing, and the enum/struct exclusion reflects that those types have no subtype to recover.
(c) Rule. The same predicate is an arm of a MATCH, with the subject left implicit (it is the match subject), and an optional DEFAULT.
Example.
MATCH shape {
WHEN IS Circle: { … }
WHEN IS Square: { … }
DEFAULT: { … } // compile-time type-check form: the else arm is mandatory
}
Rationale. Folding the same predicate into MATCH arms lets a multi-way type dispatch read as one construct instead of a chain of standalone WHEN/ELSE checks.
(d) Rule. The narrowing inside a true arm is something the compiler has proved, not something the developer has asserted — that is the whole of the difference between a WHEN check and a cast. The subject is either a type parameter, decided per template instantiation, or a value, decided from its static type. In V1 the check is resolved entirely at compile time. When the subject's static type is a more general one — a polymorphic interface or abstract base — the compiler cannot prove the concrete type, so the check takes the ELSE arm and warns. A statically-provable recovery is a V1 capability, the "identity" model. Where flow provenance proves the concrete type at compile time, the recovery binds with no cast: an interface is a lens rather than type erasure, and the value keeps its concrete identity. Consistent with "no casting, ever", a recovery is never unchecked. It is either statically discharged and free, or a compile error when the value provably is not that type (E2126). When the concrete type is not statically known — an interface-typed parameter, or the residue past an erasure boundary — it is refused with a pointer to WHEN … IS (E2127).
Example.
Conveyance c := CREATE Aircraft() // flow proves the concrete type
Aircraft a := c // move recovery — statically discharged, NO cast
REFERENCE Aircraft b =@ c // reference recovery — static_cast on the borrowed pointer, no ownership transfer
Ship s := CREATE Ferry()
Aircraft bad := s // ERROR E2126 — the value provably is NOT an Aircraft
METHOD handle(Conveyance c) RETURNS VOID {
Aircraft a := c // ERROR E2127 — concrete type not statically known; use WHEN c IS Aircraft
}
Rationale. Recovering a concrete type from a polymorphic value at run time remains a V2 capability, but a statically-provable recovery is admitted in V1 because the compiler can discharge it for free without a cast. V1 tracks provenance flow-sensitively. A control-flow join whose arms all agree on the concrete type recovers cleanly; one whose arms disagree takes the E2127 path. The branch-join precision is decided in the flow-sensitive verify pass over the IR, while the analyzer's straight-line fast-path fires the locally-provable-wrong case (E2126). V1 covers both the move recovery (Aircraft a := c) and the reference recovery (REFERENCE Aircraft a =@ c). The latter lowers to a static_cast on the borrowed pointer with no ownership transfer, behind the same provenance gate — so an unproven borrow-downcast is refused rather than emitted as clang-rejected C++. Interprocedural provenance across method boundaries is a later increment. Group membership as a WHEN … IS test is limited in V1 to a group whose members all have one concrete representation — declared in the same module, with no parametric and no GROUP member (plus the built-in Numeric); a group without that property is E6083 there. Membership as a classifier carries no such limit: it is transitive, works across a module boundary, and admits a bare parametric head (I.J.v(a), I.K.ix(c)). Cross-module group growth (GROUP X += { … }) remains V2. An individual-primitive IS (WHEN n IS int32) resolves from the subject's static type, so it holds on a type parameter or a concretely-typed value. A value whose static type is a GROUP, a runtime variant, takes the ELSE arm in V1; narrow it through a type-parameterized context instead.
(e) Rule. The compiler's static type knowledge is reflected to the developer through the IDENTITY(subject) intrinsic, whose subject is a value, a data-field path (person.spouse), a type, or a type parameter. It yields a value of the kernel STRUCT Identity whose fields the compiler fills at compile time.
| Field | Holds |
|---|---|
name |
the bare type head — "Person", "int32" |
fullname |
the parameterized spelling — "Array[int32]" |
home |
the declaring module; primitives report "ENVZN" |
type |
the type kind — exactly one of Primitive, Enum, Group, Struct, Class, Value Class, Hidden Class, Shared Class, Abstract Class, Interface, View Class |
modifier |
the binding's declared qualifier — constant, mutable, reference, mutable reference, shared mutable reference; blank for a bare type or type-parameter subject |
generic |
boolean |
internal |
boolean |
blittable |
the property of I.J.i |
currentLens |
the static type when the value is viewed through an ancestor lens — the lens of paragraph (d) made observable; blank otherwise |
lenses |
the ancestor chain in order: classes, then abstract classes, then implemented interfaces |
cases |
for an Enum subject, an EnumKind[] carrying one {name, value} pair per case in declaration order — the enum's "instances"; empty for every non-enum |
Rationale. An interface is a lens, not erasure (paragraph (d)); IDENTITY makes that identity legible without a cast — the developer reads a type's kind, structure, and provenance as ordinary data. Keeping Identity a real value, rather than compiler magic bolted onto a .field, means one type serves both the compile-time answer today and the runtime answer later. The reflected value is first-class: storable, returnable, an Identity[] element.
Part I.K.iv — Operator interfaces and Cloneable
(a) Rule. Operator overloading exists in Envzn only through implementing reserved interfaces, and never through a direct overloading syntax. A type that wishes to support an operator implements the interface the operator is derived from, and the compiler derives the operator from the interface's named methods. Every overloaded operator therefore stays traceable to a greppable method declaration. The four operator interfaces, each generic over the implementing type, are:
| Interface | Required methods | Derived operators |
|---|---|---|
Equatable[T] |
isEqualTo |
==, != |
Comparable[T] |
isLessThan |
<, >, <=, >= |
Arithmetic[T] |
add, subtract, negate |
+, - |
Multiplier[T] |
multiply, divide |
*, / |
Example.
CLASS Temperature IMPLEMENTS Arithmetic[Temperature] {
METHOD add(Temperature other) RETURNS Temperature { ... } // derives +
METHOD subtract(Temperature other) RETURNS Temperature { ... } // derives -
METHOD negate() RETURNS Temperature { ... }
}
Temperature t3 := t1 + t2 // the + is derived from add
Rationale. Routing every overloaded operator through a named interface method keeps the operator's meaning discoverable by grep and forbids the opaque free-floating operator definitions that make overloading hard to read.
(b) Rule. Comparable[T] extends Equatable[T], since an ordering implies an equality. Every numeric primitive satisfies all four automatically, and String satisfies Equatable and Comparable; a user-defined type satisfies the Numeric constraint only by implementing all four with itself as the type argument. The compiler checks that the type argument matches the implementing class — a Temperature implementing Arithmetic declares add(Temperature other), not add of some other type.
Example.
CLASS Money IMPLEMENTS Arithmetic[Money], Multiplier[Money], Comparable[Money] {
METHOD add(Money other) RETURNS Money { ... } // NOT add(int64 other) — type argument must match
METHOD isLessThan(Money other) RETURNS boolean { ... } // Comparable extends Equatable ⇒ also isEqualTo
METHOD isEqualTo(Money other) RETURNS boolean { ... }
// ... plus subtract, negate, multiply, divide to fully satisfy Numeric
}
Rationale. Because Comparable extends Equatable, an ordered type must also define equality; requiring the type argument to be the implementing class prevents an Arithmetic that secretly adds some other type, keeping the derived operators well-typed.
(c) Rule. A separate reserved interface, Cloneable, governs deep copying. Several operations need a genuine copy of an instance: a := of a class-typed value, or the *Copy collection methods of I.J. Envzn makes cloneability an explicit opt-in rather than a universal default — a class is cloneable only when it declares IMPLEMENTS Cloneable and provides a clone(). The implementing class names its own concrete type as the return type, and the interface-satisfaction rule accepts a return type that itself implements Cloneable, which yields covariant return without exposing variance modifiers. A child class does not inherit cloneability — if it needs to be cloneable it declares so itself and provides its own clone(), so that the clone covers the child's own fields and not merely the inherited ones.
Example.
CLASS Node IMPLEMENTS Cloneable {
METHOD clone() RETURNS Node { ... } // names its own concrete type — covariant return
}
CLASS TaggedNode EXTENDS Node IMPLEMENTS Cloneable { // cloneability is NOT inherited — re-declare
PRIVATE String tag
METHOD clone() RETURNS TaggedNode { ... } // own clone() so the copy covers .tag too
}
Rationale. Making cloning opt-in avoids a silent universal deep-copy default. Requiring the child to re-declare Cloneable guarantees a clone actually covers the child's own fields rather than slicing them away, and covariant return keeps clone() typed to the concrete class without variance syntax.
What Comparable and Equatable oblige.
Comparable[T].isLessThan must be a strict total order: irreflexive,
antisymmetric, transitive, and trichotomous with Equatable[T].equals — for
every pair exactly one of a < b, b < a, a == b holds. A relation that is
merely consistent is not sufficient, because a sort, a SortedList, and a
RedBlackTreeDictionary all rely on trichotomy to place a value at all.
Two consequences are normative rather than advisory:
- A sequence orders lexicographically. The first differing element decides;
if neither differs through the shorter one's length, the shorter is less.
Comparing lengths first is a different total order, not a cheaper route to
this one — it ranks
[9]below[1, 1]— and substituting it silently reorders every ordered container keyed by that type. - A type with equality but no total order implements
Equatableonly.complexis the standing example: an order may follow the real axis or the imaginary axis but not both consistently, socomplexisEquatableand is excluded from ordered containers. A hash-based dictionary is the same case for a different reason — hashing destroys the canonical enumeration that ordering would need — and so isEquatableand notComparable.
Equality is structural, and empty is equal. For a container, equals holds
when the two hold the same elements — for a dictionary, the same keys each
mapping to an equal value, not merely the same key set. Two empty
containers of the same type are equal; a definition that reports them unequal is
wrong.
Rationale. Equality must be substitutable: if two values are equal, no
subsequent query may distinguish them. Key-set equality would rank {a: 1} equal
to {a: 2}, so two "equal" dictionaries would answer lookup(a) differently.
Comparing domains is a legitimate relation, but it is not equality of the
dictionary and needs its own name.
A mutable container with structural equality is a hazard as a key. Mutating it after insertion changes what it equals, and the entry becomes unreachable.
Part I.K.v — Construction with INIT and SUPER
(a) Rule. INIT is the constructor. Primitive fields, and String or ByteBuffer fields, are auto-initialized to their defaults before INIT runs, so INIT need not assign them, though it may override them — the two containers because they are the always-present exceptions of I.D.vii and can never be EMPTY (I.E.e.i). Class-typed fields are not auto-initialized: the compiler verifies that every one is assigned on every path through INIT, and an unassigned class field is a compile error. A class with only primitive, String and ByteBuffer fields and no INIT receives a generated no-argument one. Multiple INIT blocks are permitted, overloaded by parameter signature exactly as methods are, and CREATE resolves which to call from the argument types. An INIT parameter may not carry a default value, under the rule of I.E — an omitted argument is expressed as a second INIT overload.
Example.
CLASS Point {
PRIVATE int32 x, y
PRIVATE Label tag // class-typed field — must be assigned on every INIT path
INIT(int32 px, int32 py) {
.x = px // primitives auto-init to defaults; assignment optional here
.y = py
.tag := CREATE Label("origin") // class-typed field — construct with :=
}
INIT(int32 px, int32 py, int32 scale) { ... } // a second overload — resolved by argument count
}
Point p := CREATE(3, 4) // CREATE resolves the INIT from the argument types
Rationale. Auto-initializing primitives and String, while forcing class-typed fields to be assigned on every path, guarantees no field is silently left absent. Overloading INIT by signature is what lets one class offer several construction shapes, and it is the whole mechanism: with no default parameter values (I.E(g)), a second shape is a second overload rather than an omitted argument.
(b) Rule. SUPER(args) calls the parent's INIT, and when present it must be the first statement in the child INIT — any statement before it is a compile error. It is required when the parent has only a parameterized INIT, and optional (called implicitly) when the parent has a no-argument one.
Example.
CLASS ColoredPoint EXTENDS Point {
PRIVATE String color
INIT(int32 px, int32 py, String c) {
SUPER(px, py) // must be the FIRST statement — anything before it is a compile error
.color := c
}
}
Rationale. Requiring SUPER first guarantees the parent is fully constructed before the child touches any inherited state; it is mandatory exactly when the parent offers no no-argument INIT to call implicitly.
Part I.K.vi — Methods, OVERRIDE, and MODIFY
(a) Rule. A method returning a single value writes its return type bare or parenthesized with a label, the two being equivalent. A method returning several values writes the parenthesized, every-type-labelled form, and the unparenthesized or unlabelled variants are compile errors. RETURNS VOID may be written or omitted for a method that returns nothing. When a method declares a return, every code path must end in a RETURN (...) carrying the values in declaration order, and the compiler rejects any non-void path that does not. A single-value return of a class type may legitimately yield EMPTY, for which the caller establishes presence in the usual way. A method whose result may fail uses the pipe-XOR shape of I.M, rather than encoding the failure in the value. A variadic method takes a final ... parameter of an array type, into which the compiler packs the trailing call arguments. A local variable may not shadow any field, parameter, return variable, or enclosing local, per I.E.
Example.
METHOD area() RETURNS float64 { RETURN (3.14 * .r * .r) } // single value — bare or labelled equivalent
METHOD divmod(int32 a, int32 b) RETURNS (int32 quotient, int32 remainder) {
RETURN (a / b, a % b) // multi-value — parenthesized, every type labelled, declaration order
}
METHOD reset() RETURNS VOID { ... } // VOID may be written or omitted
METHOD sum(int32... nums) RETURNS int32 { ... } // variadic — final ... parameter of array type
Rationale. The bare-versus-labelled equivalence keeps a single return terse, while the strict labelled form on multi-returns keeps each slot named. Requiring a RETURN on every non-void path makes definite-return a compile-time guarantee, and steering failure into the pipe-XOR shape of I.M keeps "absent" (EMPTY) distinct from "failed."
(b) Rule. A method is read-only by default, and a method that writes the receiver's state must declare itself MODIFY. A method must be MODIFY when its body writes a receiver field, calls a MODIFY sibling method, or calls a MODIFY method through a chain of receiver-rooted fields. It is read-only when it only reads, calls non-MODIFY methods, or returns a REFERENCE to a field for read-only use. MODIFY is part of a method's public signature — it rides in the module's generated headers so that cross-module callers see the contract — and a violation is diagnostic E1098, reported at the offending statement. MODIFY does not apply to INIT or CLEANUP, which write fields by definition, nor to AUTO methods.
Example.
METHOD balance() RETURNS int64 { RETURN (.amount) } // read-only default — only reads
MODIFY METHOD deposit(int64 n) RETURNS VOID { .amount = .amount + n } // writes a field → must be MODIFY
METHOD withdraw(int64 n) RETURNS VOID {
.amount = .amount - n // ERROR E1098 — writes a receiver field without MODIFY
}
Rationale. The polarity is deliberately the opposite of C++'s default-mutating, opt-out-with-const arrangement, and matches Swift's mutating. The motivation is const-correctness that cannot be retrofitted away. Every method's effect on its receiver is visible in its declaration from the day it is written, and because MODIFY rides in the generated headers, cross-module callers see the contract too. INIT/CLEANUP/AUTO are exempt because they write fields by definition.
(c) Rule. OVERRIDE marks the reimplementation of a concrete parent method, and is required there. Its absence on a matched concrete signature is a warning, because a reimplementation that was intended but unmarked is a latent bug. Implementing an abstract parent method, by contrast, fulfils a contract rather than replacing an implementation, and OVERRIDE there is forbidden. SUPER->method() calls the parent's concrete implementation — a qualified static call that reaches the parent's version even from a child that overrides it. SUPER is only for SUPER(args) and SUPER->method(...); a parent data field is reached with plain .field (field shadowing is forbidden, so a class has exactly one field of each name), and SUPER.field is a compile error.
Example.
CLASS Square EXTENDS Shape {
OVERRIDE METHOD describe() RETURNS String { // concrete-parent reimpl — OVERRIDE required
RETURN ("a square, not " + SUPER->describe()) // SUPER->method() calls the parent's concrete body
}
METHOD move() RETURNS VOID { ... } // abstract-parent impl — OVERRIDE FORBIDDEN
}
Rationale. Reimplementing a concrete method silently is a common source of bugs, so OVERRIDE is required to make the intent explicit. Filling an abstract slot is fulfilling a contract rather than replacing a body, so OVERRIDE there would misdescribe the relationship and is forbidden.
(d) Rule. AUTO is a method modifier that asks the compiler to generate the body deterministically from the class structure; the developer writes the signature with AUTO and leaves the braces empty. In V1, AUTO is restricted to AUTO METHOD clone(). The generated clone() deep-copies every field: primitives and value types by value copy, class-typed and Box fields by a recursive clone(), collection fields by a cascading :=. If any field's type is not itself Cloneable, the generation fails at compile time with a diagnostic naming the offending field.
Example.
CLASS Record IMPLEMENTS Cloneable {
PRIVATE int32 id // primitive — value copy
PRIVATE Label tag // class-typed — recursive clone(); Label must be Cloneable
AUTO METHOD clone() { } // empty body — the compiler generates the deep copy
}
Rationale. Deriving clone() from the class structure removes a mechanical, error-prone body while keeping the deep-copy contract honest — the Cloneable-of-every-field check makes an un-cloneable field a compile-time error naming exactly which field blocks generation.
(e) Rule. DEPRECATED marks a class or a method as obsolete without removing it. A call to a deprecated method, and a construction of a deprecated class, continue to compile and run exactly as before. DEPRECATED is advisory: it never changes runtime behaviour and never blocks a build. Every such use site draws a build-time warning, so callers can plan a migration. It is written either bare or with a single string-literal message carried into each warning. DEPRECATED may appear on a CLASS or a METHOD declaration, and it composes in any order with the other modifiers valid there — ABSTRACT, FINAL, OVERRIDE. It is not yet supported on an interface, an enum, a struct, an INIT, a field, or a local variable.
Example.
DEPRECATED CLASS OldShape { ... } // bare
DEPRECATED("use NewShape instead") CLASS OldShape { ... } // message carried into each warning
DEPRECATED METHOD legacyDraw() RETURNS VOID { ... } // on a METHOD
DEPRECATED FINAL METHOD render() RETURNS VOID { ... } // composes with FINAL, any order
Rationale. Deprecation must guide migration without breaking builds, so it is warning-only and runtime-inert. The optional message points the reader at the replacement API or a removal deadline, and limiting it to CLASS and METHOD reflects where the warning is currently wired.
(f) Rule. A method may declare type parameters of its own, independent of any its enclosing class carries. The type parameters are written in brackets after the method name, and — exactly as for a templated class (I.K.ix.b) — the declaration is preceded by a mandatory TEMPLATE: GIVEN …: qualifier line constraining every one of them: there is no unconstrained generic anywhere in the language, on a class or on a method. A generic method declared without a qualifier is diagnostic E2110, the same code a templated class draws.
Example.
TEMPLATE: GIVEN TYPE T IS Serializable:
METHOD parse[T](String text) RETURNS (T value | STATUS status) // a generic NAMESPACE method
TEMPLATE: GIVEN TYPE T IS Cloneable:
METHOD echo[T](T v) RETURNS T { RETURN (v) } // a generic CLASS method
METHOD echo[T](T v) RETURNS T { ... } // ERROR E2110 — no qualifier
Rationale. Method genericity is bounded genericity like every other kind (I.A, I.K.ix). The qualifier is what the method promises about the types its body will see, and the only thing a caller must read to know whether a candidate type fits. Putting the brackets after the name keeps the declaration and the call site the same shape, so a reader matches parse[T] to parse[Person] by eye.
(f.i) Rule. A method call may carry explicit type arguments in brackets between the method name and the argument list. These bind the method's own type parameters at the call site, distinct from the class-level type arguments the receiver already carries. Both member-access sigils admit the form: receiver->method[T](args) on an instance, and Namespace.method[T](args) on a namespace (I.F.i — a namespace has no instance, so it is reached with .). The form is unambiguous because Envzn has no first-class functions, so name[…](…) after either sigil is always type-arguments-then-call, never index-then-call. Three diagnostics guard it. Supplying type arguments to a method that declares none is E2129, and supplying the wrong number of them is E2133. A type argument that does not satisfy the method's qualifier is E2114 — the same code, and the same satisfaction rule, a class instantiation draws.
Example.
receiver->method[T](args) // instance receiver — the `->` form
Json::Codec.parse[Person](text) // NAMESPACE receiver — the `.` form
CREATE Foo[T](args) // the mirrored construction form
plain->reset[int32]() // ERROR E2129 — reset declares no type parameters
Codec.parse[Person, Order](text) // ERROR E2133 — parse declares ONE type parameter
Codec.parse[Plain](text) // ERROR E2114 — Plain is not Serializable
Rationale. The two sigils are not interchangeable (I.F.i), so restricting call-site type arguments to -> would have put the whole namespace surface — including the serialization façade Codec.parse[T] — out of reach of method genericity for no reason. Enforcing the qualifier at the call site (E2114) is what keeps the constraint binding rather than decorative: without it, TEMPLATE: GIVEN …: on a method would be a comment.
Part I.K.vii — Polymorphic collections
(a) Rule. An interface-typed reference is dynamically dispatched, with no keyword required. A collection typed to an interface holds any concrete type that implements it, and a method call through the interface reaches the correct concrete implementation. The mechanism is an ordinary vtable — one per implementing class, one pointer indirection per call — and ENVZN_IR_SPEC.md C.iv describes how it is emitted. The element interface must satisfy the collection's template qualifier like any other type argument (I.K.ix). For a Cloneable-bounded collection such as Array, that means the element interface EXTENDS Cloneable, whose clone() the compiler auto-narrows to that interface — see the heterogeneous-element note in I.K.ix.
Example.
Drawable[] shapes
shapes->append(CREATE Circle(5.0) AS handle)
FOR shape IN shapes {
shape->draw(0, 0) // dynamic dispatch — the right method for each concrete type
}
Rationale. Interface-typed dispatch needs no keyword because the interface handle is itself the signal; the vtable gives one indirection per call, so a collection can hold a heterogeneous mix of implementers and still reach each one's own method. The qualifier-satisfaction requirement is what ties this to the generic machinery of I.K.ix.
Part I.K.viii — Enumerations
(a) Rule. An enum declares a closed set of named cases, in three forms chosen by the data each case carries. A simple enum has cases with no associated data, for flags, directions, and states. A raw-value enum gives each case a primitive value of one shared type, read through .value. An associated-value enum lets each case carry typed data of its own shape, extracted by binding in a WHEN arm, and its cases may be mixed — some carrying values, some not.
Example.
ENUM Direction { NORTH SOUTH EAST WEST } // simple
ENUM HttpStatus { OK = 200 NOT_FOUND = 404 } // raw-value — read through .value
ENUM Shape { // associated-value
CIRCLE(float64 radius)
RECTANGLE(float64 width, float64 height)
POINT // cases may be mixed — POINT carries no data
}
Rationale. Three forms cover the three real shapes of a closed set — bare states, states tagged with one primitive, and states each carrying their own record — without forcing an author to model one as another.
(b) Rule. The integer value that backs each case follows one set of rules across all three forms. An implicit first case takes the value 1, and each subsequent implicit case takes the previous case's value plus one. An explicit = N may appear at any position, and the next implicit case after an explicit one continues from N + 1. Duplicate values are permitted — the cases remain distinct names regardless of whether their backing integers collide, and equality across enum values is by case identity rather than by raw value. The mixing of explicit and implicit cases inside a single enum carries no warning — the earlier warning W10041 against mixed-raw enums is retired.
Example.
ENUM Level { // backing integers:
LOW // 1 (implicit first case)
MEDIUM // 2 (previous + 1)
HIGH = 10 // 10 (explicit)
CRITICAL // 11 (continues from N + 1)
}
Rationale. Case identity, not the backing integer, defines an enum value's equality, so duplicate backing values are harmless and mixing explicit with implicit needs no warning — which is why W10041 was retired.
(c) Rule. An enum has no methods. It is a named, ordered list of cases with numbers attached to them, and that is the whole of it: an enum body admits cases and nothing else. It may use neither IMPLEMENTS nor EXTENDS (E6019) — with no methods there is no contract to adopt and nothing to inherit. Behaviour that varies by case belongs on a class or a namespace that takes the enum as a value, where it is visible as ordinary dispatch. Simple and raw-value enums automatically satisfy Equatable; associated-value enums do not, because their cases carry varying data and equality is not automatically well-defined.
Example.
ENUM Direction { NORTH SOUTH EAST WEST }
ENUM Bad IMPLEMENTS Describable { // ERROR — an enum adopts no contract
A B
METHOD f() RETURNS VOID { ... } // ERROR — an enum has no methods
}
Rationale. An enum names a closed set of alternatives; it is data, not a type with behaviour. Keeping it that way means a reader who finds an enum knows there is nothing hidden in it — no dispatch, no state, no contract — and that describing a case is the job of whatever consumes the enum. Article III's grammar has always said as much: enum_decl admits cases alone. Automatic Equatable is safe for simple and raw-value enums but not for associated-value ones, whose per-case data makes equality something the author must define.
Part I.K.ix — Template qualifier expressions
(a) Rule. A type parameter is the tool for a homogeneous abstraction: a container or algorithm that works over one type at a time, uniformly, without inspecting or branching on which type it was handed. The payoff is that it preserves that type exactly across its surface. An Array[T] yields a T rather than a common supertype to be cast back down, and stores it without the boxing or dynamic dispatch a "collection of the shared base type" would impose. Where an abstraction must instead hold a mix of differing concrete types, or stay open to implementers written later, the right instrument is an interface and subtype dispatch (I.K.i–I.K.iv), not a type parameter. Two boundaries follow, both fixed: first, there is no unconstrained generic — every type parameter states the contract its arguments must satisfy, and the language rejects an ANY escape hatch; second, generics are not a compile-time programming language — Envzn has, and will have, no type-level computation, no substitution-driven overload selection, and no expression templates or recursive instantiation pressed into service as a metaprogramming engine.
Rationale. A generic parameterizes a type or an algorithm over types; it does not compute on them, dispatch on them, or reflect over them. This second boundary is drawn from experience rather than theory. The mechanism that began as the type-safe container — an unambiguous good — became, once allowed to grow into a metalanguage, the largest single source of unreadability in the language Envzn most resembles. Envzn keeps the container and refuses the metalanguage, permanently, not as a limitation awaiting a later release (this is the bounded genericity principle of I.A). The heterogeneous-element note in (e) is precisely the seam at which a generic container borrows the interface mechanism rather than competing with it.
(b) Rule. Every templated class — every CLASS declared with one or more type parameters — must carry a qualifier expression naming the inclusive set of types the template will accept. The qualifier sits on the line immediately above the class header and is mandatory: a templated CLASS without one is a compile error at the definition site. There is no implicit "anything goes" constraint; the language deliberately rejects an ANY escape hatch.
Example.
TEMPLATE: GIVEN TYPE T IS Cloneable, PRIMITIVE (EXCEPT boolean):
FINAL CLASS Array[T] IMPLEMENTS Cloneable { … }
FINAL CLASS Bag[T] { … } // ERROR — a templated CLASS with no TEMPLATE: qualifier line above it
Rationale. The intent is contractual rather than mechanical. The qualifier is what the template author promises about the shape of types its body will see, and the only thing a downstream developer needs to read to know whether a candidate type fits.
(c) Rule. The single-type form names one type parameter and one constraint; the multi-type form names several and binds a constraint to each. A constraint is an inclusive list of atoms separated by commas, where comma reads as or. An alternative may be wrapped in parentheses with the keyword AND to require every atom in the group, and that parenthesized form is the only way to express and at the constraint level. An atom is the name of a class, an abstract class, an interface, or a GROUP, or the reserved name PRIMITIVE — the latter a built-in meta-category meaning "any primitive type" rather than a user-definable group. After an atom that is a GROUP or PRIMITIVE, an (EXCEPT a, b, …) clause may narrow the membership by removing specific members; EXCEPT is not legal after a class, abstract class, or interface atom, since those name a single membership rather than an enumerable set. A candidate type T satisfies the constraint when it satisfies at least one alternative, and an alternative is satisfied when every atom in it is satisfied. A class atom is matched by an identical class or a transitive subclass. An abstract or interface atom is matched by any concrete implementer, and also by the interface or abstract type itself and by any sub-interface that transitively EXTENDS it — so a template may be instantiated over an interface element type, as (e) describes. A GROUP atom is matched by any transitive member not removed by the carve-out — a type belonging to a member group satisfies the enclosing group — and PRIMITIVE by any primitive name not removed. Where a group member is a bare parametric head (Array), the atom is matched by every instantiation of it (Array[int32], Array[String]), since the head is what the group names. An EXCEPT name that is itself a GROUP removes that group's members, not merely the name: Collectable (EXCEPT Text) removes String and its siblings, because a group name means its members on the exclude side exactly as it does on the include side.
Example.
TEMPLATE: GIVEN TYPE T IS Cloneable, PRIMITIVE (EXCEPT boolean):
FINAL CLASS Array[T] IMPLEMENTS Cloneable { … }
TEMPLATE: GIVEN TYPES K, V; K IS PRIMITIVE (EXCEPT boolean), String;
V IS Cloneable, PRIMITIVE (EXCEPT boolean):
FINAL CLASS Dictionary[K, V, H] IMPLEMENTS Cloneable { … }
TEMPLATE: GIVEN TYPE T IS Printable, (Readable AND Writeable): // parenthesized AND — every atom required
FINAL CLASS Stream[T] { … }
Rationale. Comma-as-or with a parenthesized-AND group gives exactly the two combinators a bounded constraint needs, and no more. The EXCEPT carve-out is allowed only where the atom names an enumerable set — GROUP, PRIMITIVE — since removing a member from a single class or interface would be meaningless. Admitting the interface/abstract type itself as a match is what lets a container be instantiated over an interface element type.
(d) Rule. The qualifier is parser-and-analyzer surface only; the emitter is unaffected. A doc-comment written above the TEMPLATE: block attaches to the class that follows, not to the qualifier — the qualifier is part of the class's contract, not a separate documented entity. Constraint enforcement at an instantiation site happens whenever a Foo[X] reference is reachable through a field, parameter, return type, or local. Each type argument is checked against the matching constraint, and a failure surfaces a single general code rather than a per-template special case. Type-parameter references inside the template's own body (e.g. Array[T] written within Array.ev's methods) skip the satisfaction check, since T is a type variable bound by the enclosing template rather than a concrete type; the constraint propagates at the outer instantiation site.
Example.
Array[Point] pts // instantiation site — Point checked against T IS Cloneable, PRIMITIVE …
Array[boolean] flags // ERROR — boolean is EXCEPTed from the qualifier
// inside Array.ev's own methods:
METHOD first() RETURNS T { ... } // T is a bound type variable here — no satisfaction check
Rationale. Because T inside the body is a bound variable rather than a concrete type, checking it there would be meaningless. The check belongs at the outer Foo[X] reference, where X is concrete, and a single general diagnostic keeps the failure uniform across every template.
(e) Rule — Heterogeneous interface-element collections. Because an interface atom is satisfied by the interface itself, and by any sub-interface that EXTENDS it, a collection may be instantiated over an interface element type. That holds a heterogeneous mix of implementers dispatched through the interface contract: Array[Cloneable], or more usefully Array[LogDestination] where INTERFACE LogDestination EXTENDS Cloneable. Each element is stored by owning handle to the interface and method calls dispatch virtually to the concrete implementer; append/MOVE and read/dispatch require nothing beyond this rule. When the element interface is Cloneable, directly or transitively — as it must be to satisfy a Cloneable-bounded collection such as Array — its clone() must statically return the element interface, not the base clone() RETURNS Cloneable. Otherwise the collection's deep-copy path cannot store the clone back into an interface-typed slot. The compiler supplies this narrowing automatically: any interface that EXTENDS Cloneable (directly or transitively) is emitted with a covariant clone() returning that interface, so Array[LogDestination]->clone() works with nothing more than the EXTENDS Cloneable declaration. Writing the narrowing by hand (METHOD clone() RETURNS LogDestination) remains legal and, where present, takes precedence over the auto-generated form; it is no longer required.
Example.
INTERFACE LogDestination EXTENDS Cloneable { // compiler auto-narrows clone() RETURNS LogDestination
METHOD emit(String line)
}
Array[LogDestination] sinks // heterogeneous mix of implementers, dispatched through the interface
Rationale. This is the same covariant-return-via-subtype rule of I.K.iv, and it carries real weight here. The collection's own deep-copy path stores the clone back into an interface-typed slot, so clone() must statically return the element interface rather than the Cloneable root. The compiler supplies that covariant narrowing automatically for every Cloneable-extending interface (rather than diagnosing its absence), keeping the readable surface free of the boilerplate the mechanism requires — cost relocated inward. This admits element polymorphism — a container of differing implementers — and is distinct from container variance: an Array[ConsoleDestination] is still not assignable to an Array[LogDestination] parameter, as Envzn has no variance over type parameters.
(f) Rule — Type-parameter syntax, OF versus brackets. Envzn writes a type parameter in one of two forms, fixed by what is being parameterized so that a generic's parameter list is never confused with the array-type suffix T[]. A class is parameterized with brackets everywhere it appears — at its declaration, at every CREATE, and in every type position (field, parameter, return, local). An interface with a single type parameter is parameterized with OF, both at its declaration and wherever it is referenced. An interface with more than one type parameter uses brackets, because OF binds a single argument and has no multi-argument form. Inside a template qualifier an atom is named bare (T IS Comparable), and on the rare occasion a bound is itself parameterized it takes the OF form. A WHEN … IMPLEMENTS conditional — a type-test expression, not a declaration — names the interface in bracket form.
Example.
FINAL CLASS Array[T] // class — brackets at declaration
CREATE Array[T]() // … at CREATE
Array[T] xs // … in a type position
INTERFACE Iterator OF T // single-param interface — OF
IMPLEMENTS Comparable OF T
EXTENDS ReferenceIterator OF T
ReferenceIterator OF T cursor
INTERFACE Dictionary[K, V, H] // multi-param interface — brackets (OF has no multi-arg form)
IMPLEMENTS Dictionary[K, V, H]
WHEN T IMPLEMENTS Comparable[T] { … } ELSE { … } // type-test — bracket form
Rationale. The single guiding distinction is that OF is the unambiguous interface-parameterization keyword, while brackets carry both class generics and the array suffix. The two are told apart by content: a bracket holding a type name is a generic argument list, and one that is empty or holds a size ([512+]) is an array. Fixing each construct to one form keeps a generic's parameter list from ever being read as T[].
(g) Rule. GROUPs named in a qualifier belong to the module that defines them. A module wanting to use its own enum or struct as an atom defines a GROUP containing it, because enums and structs are not meta-categories of their own.
Example.
GROUP Numeric { int32, int64, float64, Money } // a module-local GROUP wrapping the desired atoms
TEMPLATE: GIVEN TYPE T IS Numeric:
FINAL CLASS Accumulator[T] { … }
Rationale. This keeps the closed-membership story honest: the set of types satisfying a constraint must always be reachable through one of the listed buckets. The buckets themselves can still grow — a new class implementing an interface, or a new member added to a group, automatically satisfies any constraint naming that interface or group.
Forwarding a type parameter into another parametric type.
When a class names another parametric type with one of its own type parameters in a slot, its qualifier for that parameter must be at least as tight as the receiving slot requires. There are two such places and both bind:
- a field, where the class holds the other type; and
- an
IMPLEMENTS/EXTENDSclause, where the class is the other type.
TEMPLATE: GIVEN TYPES K, V, H; V IS Cloneable: // too weak …
CLASS MyDict[K, V, H] IMPLEMENTS Dictionary[K, V, H] { // … Dictionary's V slot
// requires Equatable[V]
The obligation is checked at the declaration, which is the earliest point it
exists and the point a reader can act on: the forwarding class's own qualifier is
what must change, not the use site that eventually instantiates it. Absent this,
a CREATE resolves against the looser class qualifier and the violation
surfaces far away — as a failure inside a generated method, in terms of the
target language rather than of Envzn.
Three facts the comparison honours, because the language already owns them and a name-by-name reading does not:
GROUPnesting.GROUP HashKey { WordKey, decimal128 }meansK IS WordKeysatisfies a slot requiringHashKey.- Category atoms.
PRIMITIVEis satisfied by a qualifier constraining to primitive type names or a primitive group.CLASSandBLITTABLEare decided by the language, never granted by a qualifier, so a forwarder is never required to restate them. - Alternatives are alternatives. "V may be a primitive" does not cover "V may
be any
Cloneableclass"; each alternative of the caller must be covered by some alternative of the callee.
An interface's qualifier may demand only what its own methods use. A contract
the interface never exercises excludes implementors for no reason — and the
exclusion is invisible until some type that satisfies every method is refused
at the qualifier. An iterator that hands back references never clones, so it may
not require Cloneable; a Collection whose surface is size, isEmpty and
iterator asks nothing of its element and its qualifier must say so.
Section I.L — Concurrency
Envzn's structured-concurrency model: a developer writes tasks inside a scope rather than raw threads, and the channel and switchboard communication, the capture rules, and the synchronization surface all follow from that one decision.
(a) Rule. A developer who writes concurrent code in Envzn does not write threads — they write tasks inside a scope. The unit of concurrent work is a Task; the lifetime of every task is bound to a CONCURRENT { … } block, which is its scope. The everyday patterns live in the standard library as named, typed classes (I.L.vi), not as primitives the developer assembles by hand: a pool of workers draining a queue of jobs, a pipeline of stages, a broker fanning a typed message to a set of subscribers. The lower primitives those patterns are built on — Channel, Future, Mutex, Atomic — remain in the kernel for the library implementer and for the rare case the standard pattern doesn't fit.
Example.
CONCURRENT {
WorkerPool[Job] pool := CREATE WorkerPool[Job](4) // reach for the named pattern, not raw threads
FOR job IN incoming {
pool->submit(MOVE job)
}
} // block does not exit until every worker has joined
Rationale. Threads are meant to be out-of-mind and the pattern the natural reach — the tutorials and examples do not lead with the raw primitives because ordinary code does not spend its time there. Every supporting decision in this section falls out of the one named idea: tasks live inside a scope.
Part I.L.i — The structured-concurrency scope (CONCURRENT)
(a) Rule. A CONCURRENT { … } block opens a structured scope. Its body executes on the calling thread, and the tasks launched within it — each through a PARALLEL block (I.L.ii) — are bound to it. The block does not exit until every task it launched has joined or been cancelled. The scope owns the lifetime of every Channel, Future, and cancellation token created inside it.
Example.
CONCURRENT {
Channel[int32] work := CREATE Channel[int32](16) // owned by this scope — closed on exit
PARALLEL { producer(work) } // launched task, bound to this block
PARALLEL { consumer(work) }
} // both tasks joined here before control returns
Rationale. This is structured programming applied to concurrent work. The essential guarantee is that a function call cannot leave background work running after it returns — the scope's brace is a join point, not just a lexical boundary.
(b) Rule. The first task to fail cancels its siblings, and the failure propagates to the block's enclosing scope.
Example.
CONCURRENT {
PARALLEL { stageA() } // if stageA fails …
PARALLEL { stageB() } // … stageB is cancelled, and the failure re-raises past this block
}
Rationale. Sibling cancellation on first failure keeps a scope from lingering while one task has already lost — the scope is all-or-nothing, and the caller sees the failure rather than a silently half-finished result.
(c) Rule. A task that genuinely needs to outlive its enclosing scope is written with the deliberately ugly DETACHED CONCURRENT { … } form, the language's explicit escape hatch. In V1 that form is reserved and rejected — the keyword is held against the namespace and the parser refuses its use — so every V1 task is bound to a structured scope; the escape hatch arrives in V2.
Example.
DETACHED CONCURRENT { … } // ERROR in V1 — DETACHED reserved, parser rejects; arrives V2
Rationale. Holding the keyword against the namespace now means no V1 code can spawn unstructured background work, and no future code has to fight for the name when the escape hatch ships.
(d) Rule. Cancellation is cooperative throughout: the enclosing scope's cancellation token is implicit, channel operations and explicit yield points observe it, and a tight CPU loop that ignores cancellation is a smell the analyzer warns on.
Example.
CONCURRENT {
PARALLEL {
WHILE NOT currentTask->isCancelled() DO { // observes the implicit token
grind()
}
}
PARALLEL {
WHILE work->receive() DO { // receive() is an implicit yield/cancel point
process($RETURNED)
}
}
}
Rationale. Making the token implicit keeps the cancellation plumbing off the developer's surface, cost relocated inward. The analyzer warning catches the one case where cooperation breaks down: a CPU loop with no yield point, which could never notice it was cancelled.
Part I.L.ii — Launching a task (PARALLEL)
(a) Rule. A task is launched by a PARALLEL { … } block. PARALLEL is legal only inside a — transitively — enclosing CONCURRENT scope; a PARALLEL block with no enclosing CONCURRENT is a compile error.
Example.
CONCURRENT {
PARALLEL { doWork() } // OK — scope to join is right here
}
PARALLEL { doWork() } // ERROR — no enclosing CONCURRENT, nothing to join it
Rationale. A task with no scope to join is exactly the unstructured background work the whole model exists to forbid — so the compiler refuses it at the source.
(b) Rule. Each PARALLEL body runs as a task — on its own OS thread in the V1 runtime — and registers with the innermost enclosing CONCURRENT scope, which is the scope responsible for joining it. The join model is therefore flat rather than tree-shaped: every task joins the nearest scope directly, and nesting blocks does not nest the joins.
Example.
CONCURRENT { // outer scope
PARALLEL { outerTask() } // joins the OUTER scope
CONCURRENT { // inner scope
PARALLEL { innerTask() } // joins the INNER scope — the nearest one
} // innerTask joined here, not deferred to the outer brace
}
Rationale. Registering with the innermost scope makes the join site predictable from the code's brace structure alone — you read where a task ends by finding the nearest enclosing CONCURRENT, with no tree-walk to reason about.
(c) Rule. currentTask is a compiler-injected reference to the executing task — the task equivalent of SELF, reachable inside a PARALLEL body and nowhere else. The only operations valid on it are sleep, isCancelled, and reading the task's name; it cannot be stored, assigned, or passed.
Example.
CONCURRENT {
PARALLEL {
currentTask->sleep(100) // OK — sleep
IF currentTask->isCancelled() THEN { RETURN } // OK — isCancelled
String name := currentTask->name() // OK — read the name
Task t := currentTask // ERROR — currentTask cannot be stored, assigned, or passed
}
}
currentTask->sleep(1) // ERROR — currentTask reachable only inside a PARALLEL body
Rationale. Locking currentTask to three read-only operations, and forbidding it from escaping, keeps a task handle from leaking into shared state. There it would reintroduce exactly the unstructured lifetimes the scope model removes — the same discipline SELF gets, applied to tasks.
Part I.L.iii — Point-to-point: Channel[T]
(a) Rule. Point-to-point communication between tasks passes through a Channel[T] — typed, bounded by default, and the V1 primitive over which Producer-Consumer is expressed without a separate library. A Channel[T] is constructed with an explicit capacity; there is no zero-argument constructor.
Example.
Channel[int32] c := CREATE Channel[int32](32) // capacity 32 — required
Channel[int32] bad := CREATE Channel[int32]() // ERROR — no zero-argument constructor
Rationale. Forcing an explicit capacity makes the backpressure decision visible at the construction site rather than defaulted silently — a bounded channel is the safe default, and choosing its size is a choice the reader can see.
(b) Rule. When the capacity fills, a send() either blocks, drops the oldest, drops the newest, or conflates with the most recent — by a policy declared at construction. The unbounded form is the separately-named UnboundedChannel[T], for the case where the developer can prove from local information that the channel cannot grow without bound. Using it is a deliberate, named choice, and the analyzer surfaces every use as a review point. (UnboundedChannel[T] is reserved for V2 and is not available in V1.)
Example.
Channel[Reading] c := CREATE Channel[Reading](8, ChannelFull.CONFLATE) // full-policy at construction
// UnboundedChannel[Log] u := CREATE UnboundedChannel[Log]() // V2 — named, deliberate; every use a review point
Rationale. Naming the unbounded channel differently, rather than expressing it as a capacity flag, makes unbounded growth a word a reviewer can grep for — the risky choice is spelled out, not hidden in an argument.
(c) Rule. Ownership transfers on a send, and the sender may not touch the value after. A receiver takes a value through the channel's iterator — which blocks per item and exits cleanly on close — or through an explicit receive() call, which returns the pipe-XOR shape of I.M.i.
Example.
// sender — ownership moves out on send
c->send(MOVE reading)
use(reading) // ERROR — reading was moved into the channel
// receiver A — receive() in a WHILE (blocks per item, ends cleanly when the channel closes)
WHILE c->receive() DO { process($RETURNED) }
// receiver B — explicit receive(), consumed as the pipe-XOR of I.M.i
IF c->receive() THEN { Reading r := $= } // $= = the received value
ELSE { printline($!) } // $! = fail message (channel closed / drained)
Rationale. Move-on-send means the value is never aliased across the boundary — no data race is even expressible — and reusing the pipe-XOR result shape (I.M.i) means a receive reads exactly like every other fallible read in the language.
(d) Rule. A channel closes automatically when its variable goes out of scope, signalling the end of the stream to receivers; closure is the channel's own concern and does not pass through a separate routing layer.
Example.
CONCURRENT {
Channel[int32] c := CREATE Channel[int32](4)
PARALLEL { FOR i = 0 TO 9 DO { c->send(MOVE i) } }
PARALLEL { WHILE c->receive() DO { handle($RETURNED) } } // exits cleanly when c closes
} // c goes out of scope → closes → receiver's WHILE ends
Rationale. Tying closure to scope exit means the end-of-stream signal is automatic — no close() for a producer to forget — and keeping it the channel's own concern avoids a routing layer between producer and consumer.
Part I.L.iv — Broadcast: Broker[T]
(a) Rule. Broadcast — one publisher delivering a typed message to many subscribers — is a separate primitive, the Broker[T], and it replaces every earlier sketch of a routing-singleton design. A Broker[T] carries values of exactly one type; the topic is the type. There are no string-keyed topics, no untyped messages, and no broker-side state outliving the subscriber that put it there.
Example.
Broker[PriceTick] ticks := CREATE Broker[PriceTick]() // the topic is the type PriceTick
ticks->publish(MOVE tick)
Rationale. Making the type the topic removes the whole class of string-typo and untyped-payload bugs a keyed pub/sub carries — the compiler already knows what flows through this broker.
(b) Rule. A subscriber subscribes through its enclosing CONCURRENT scope, declaring its own bounded buffer capacity and backpressure policy at subscribe time; the subscription's lifetime ends when the scope ends. There is no manual unsubscribe, no leaked subscription, and no broker-owned reference that outlives the subscriber.
Example.
CONCURRENT {
PARALLEL {
Channel[Tick] feed := ticks->subscribe(16, BackpressurePolicy.DROP_OLDEST) // own buffer + policy
WHILE feed->receive() DO {
render($RETURNED)
}
}
} // scope ends → subscription ends, nothing to unsubscribe
Rationale. Binding the subscription's lifetime to the scope is the same structured guarantee as the tasks themselves — a subscription cannot leak past the code that owns it, so the broker never holds a dangling reference.
(c) Rule. The hot path is lock-free: an atomic publish-index on the broker, a per-subscriber ring buffer for the queued backlog, and a single compare-and-swap on multi-publisher publish. Observable counters per subscriber report delivered, dropped, and high-water-mark for diagnostics. Subscribers do not see each other, and a slow subscriber does not starve a fast one — its own buffer fills and its declared policy decides what happens next.
Rationale. Per-subscriber ring buffers isolate consumers from one another, so throughput is set by each subscriber's own policy rather than by the slowest in the set. The lock-free publish path and the diagnostic counters are cost relocated inward: fast machinery under a readable surface.
Part I.L.v — Shared state: Mutex, Atomic, and SYNCHRONIZED
(a) Rule. Shared mutable state, where it is unavoidable, is reached through Mutex[T] — a wrapper whose data lives inside the lock and is reachable only inside a SYNCHRONIZED(mutex) { … } block bound to that mutex. The bare lock() / unlock() API is not exposed: a value protected by a mutex is touched only inside the block, and the block's exit releases it.
Example.
Mutex[int32] counter := CREATE Mutex[int32](0) // the data lives inside the lock
SYNCHRONIZED(counter) { // the only way to reach the protected data
counter.data := counter.data + 1 // `.` — data is a field, not a method
}
counter->lock() // ERROR — bare lock()/unlock() is not exposed
Rationale. Putting the data inside the lock makes it structurally impossible to touch the value without holding the lock — you cannot forget to acquire a lock you can only reach through a SYNCHRONIZED block.
(b) Rule. For lock-free single-word state, Atomic[T] over the primitive widths provides a load / store / compareExchange surface.
Example.
AtomicInt64 seq := CREATE AtomicInt64(0) // concrete AtomicX — the parametric Atomic[T] is V2
int64 now := seq->load()
seq->store(now + 1)
IF seq->compareExchange(now, now + 1) THEN { … } // CAS
Rationale. A single-word atomic needs no critical section — exposing exactly load/store/compareExchange gives the lock-free path its three operations and nothing that would tempt a developer to build a fragile ad-hoc lock out of them.
(c) Rule. Beneath both sits the SYNCHRONIZED(lock) { … } block, the language's explicit critical section. It acquires the named lock for the duration of its body and releases it automatically on every exit path — ordinary fall-through, RETURN, BREAK, or a PANIC unwinding through it. No path can leave the lock held. A Mutex[T]'s access block is precisely a SYNCHRONIZED block bound to that mutex, and a SHARED CLASS monitor (I.K.ii) wraps its own critical sections in SYNCHRONIZED by hand.
Example.
SYNCHRONIZED(lock) { // acquires lock for the body
IF done THEN { RETURN } // released on RETURN
IF skip THEN { BREAK } // released on BREAK
risky() // released even if this PANICs and unwinds through the block
} // released on ordinary fall-through
Rationale. Releasing on every exit path, including a PANIC unwind, is the whole point of a scoped critical section. No code path, ordinary or exceptional, can leave the lock held — which is the failure mode manual lock/unlock invites.
Part I.L.vi — Future and the pattern library
(a) Rule. Future[T] is the eventually-ready cell that WorkerPool and the V2 patterns return for submitted work, sharing its construction and consumption shape with the pipe-XOR result of I.M.i.
Example.
CONCURRENT {
Future[int32] f := pool->submit(MOVE job) // returns an eventually-ready cell
IF f->await() THEN { int32 result := $= } // consumed as the pipe-XOR shape of I.M.i
ELSE { printline($!) }
}
Rationale. Reusing the pipe-XOR shape (I.M.i) means a future's result reads like every other fallible value in the language — nothing new to learn to consume asynchronous work.
(b) Rule. The standard concurrent patterns ship as named stdlib classes built atop the primitives of the parts above, and they are what idiomatic Envzn code reaches for. V1 ships the foundation: Task, CONCURRENT, PARALLEL, Channel[T], Future[T], Mutex[T], the concrete AtomicX classes, and Broker[T]. It ships one canonical pattern with them — WorkerPool[T], the language's worker-pool and producer-consumer implementation in a single named class, constructed inside a CONCURRENT block and parameterised by pool depth. (UnboundedChannel[T] and the parametric Atomic[T] sugar are reserved for V2; see the Appendix.)
Example.
CONCURRENT { // WorkerPool is constructed inside a CONCURRENT block
WorkerPool[Request] pool := CREATE WorkerPool[Request](8) // parameterised by pool depth
FOR req IN requests { pool->submit(MOVE req) }
} // pool drains and joins at the brace
Rationale. One named class for the worker-pool/producer-consumer case is the pattern-is-the-reach stance made concrete: ordinary V1 code names WorkerPool rather than wiring Channel + PARALLEL by hand.
(c) Rule. (V2 reserved — pattern library.) V2 fills out the pattern library: Pipeline[In, Out] for chained-stage processing, ScatterGather for parallel-map over a typed input collection, and ForkJoin for divide-and-conquer over a work-stealing scheduler. Actor[State, Message] for isolated stateful entities, and Supervisor for restart-strategy fault tolerance. RateLimiter, CircuitBreaker, and Bulkhead for the resilience family, and Flow[T] for cold, demand-driven streams with backpressure operators.
Rationale. The V2 set is named and reserved now so the pattern vocabulary is planned as a whole rather than accreted ad hoc — each name is a slot already accounted for in the design.
(d) Rule. (V3 reserved — long tail and scheduler graduation.) V3 adds the long tail: Reactive Streams spec interop, Saga coordination, specialized channel variants. It is also where the runtime's task scheduler graduates from OS threads to a tuned virtual-thread M:N implementation. The V1 surface deliberately looks the same on either runtime so the V3 schedule swap does not change the code a developer wrote.
Rationale. Fixing the surface now and swapping the scheduler later is cost relocated inward at the largest scale: the V1 developer writes against tasks-in-a-scope, and the M:N virtual-thread runtime arrives in V3 without a single source change.
Part I.L.vii — Capture rules for task bodies
(a) Rule. Because a PARALLEL task body must be self-contained, its captures are restricted: the body may reference channels, brokers, futures, compile-time constants, and values it owns outright because they were moved into it before the CONCURRENT block. It may not call an instance method of the enclosing class, since that implicitly captures the instance, and it may not read shared mutable state of an enclosing scope.
Example.
Channel[int32] c := CREATE Channel[int32](8)
CONCURRENT {
PARALLEL {
c->send(MOVE 1) // OK — channel reference is permitted
int32 n := MAX_BATCH // OK — compile-time constant
.helper() // ERROR — instance method implicitly captures the enclosing instance
n := .sharedCounter // ERROR — reading shared mutable state of an enclosing scope
}
}
Rationale. Restricting captures to things that are immutable, uniquely owned, or a communication primitive is what makes a task body safe to run on another thread without a lock. An implicit-SELF call, or a read of enclosing mutable state, would smuggle a shared, unsynchronized alias into the task.
(b) Rule. The compiler distinguishes a permitted local call from a forbidden implicit-SELF call by ordinary name resolution: a bare call resolves either to a local or to a method, and only the first is allowed inside a task body.
Example.
CONCURRENT {
LAMBDA transform: worker(transform) // a bare call resolving to a local — OK inside a task body
PARALLEL {
worker(x) // OK — resolves to a local
process(x) // ERROR — resolves to an instance method (implicit SELF capture)
}
}
Rationale. Reusing ordinary name resolution means the rule needs no new capture-annotation syntax — whether a bare call is legal in a task body is already decided by the same lookup that decides what the call means.
Section I.M — Error Handling
The two deliberately separated failure mechanisms — recoverable failure through STATUS and the return shapes, and unrecoverable failure through PANIC and RECOVER — and why conflating them is forbidden.
(a) Rule. Envzn separates two kinds of failure and gives each its own mechanism. A recoverable failure is an ordinary outcome a caller is expected to handle — a parse that did not succeed, a key that was not present — and it is communicated through return values (STATUS and the return shapes). An unrecoverable failure is a defect or a genuinely exceptional condition, and it is communicated by unwinding the stack (PANIC/RECOVER).
Example.
IF parser->scan(source) THEN { ... } ELSE { ... } // recoverable — handled through the return shape
PANIC FileNotFoundError("no config", path) // unrecoverable — unwinds the stack
Rationale. Conflating the two is a reliable way to make error handling either too noisy or too easy to ignore. Routing every ordinary miss through the stack-unwinding path buries expected outcomes in exception machinery; routing genuine defects through return values invites callers to drop them silently. Keeping the vocabularies apart forces each failure to travel the channel that matches how it must be handled.
Part I.M.i — Recoverable failure: STATUS and the return shapes
(a) Rule. STATUS is the built-in type for recoverable outcomes. It carries one of three literals — SUCCESS, PARTIAL_SUCCESS(message), and FAILURE(message). Both FAILURE and PARTIAL_SUCCESS may carry a second argument, an int32 code, for a machine-readable error number alongside the human-readable message. The one-argument forms remain valid and mean a code of 0 — the value reserved for no numeric code, not for a meaningful zero. A STATUS is treated like a boolean literal and assigned with =.
Example.
STATUS ok = SUCCESS
STATUS f2 = FAILURE("open failed", 2) // message + int32 code
STATUS p2 = PARTIAL_SUCCESS("3 of 5 written", 3) // two-argument form
STATUS f1 = FAILURE("open failed") // one-arg — code is 0 (no code)
STATUS p1 = PARTIAL_SUCCESS("wrote most of it") // one-arg — code is 0
Rationale. The int32 code is where a numeric outcome from outside the program is carried: an errno from a SETS_ERRNO foreign binding, an HTTP status, a C-library result code. It rides alongside the message rather than being smuggled into it. Reserving 0 for no code keeps the one-argument form honest: a caller reading a 0 knows no external number was supplied rather than mistaking a meaningful zero.
(a.i) Rule. A STATUS literal is valid in exactly three places — the right side of a STATUS assignment, a WHEN arm of a MATCH, and the right side of an IS check in a plain IF. Writing one anywhere else is a compile error. A MATCH arm binds the code alongside the message, WHEN FAILURE(msg, code), and a pipe-XOR caller reads the code through the $# token described below.
Example.
STATUS s = readConfig(path) // (1) right side of a STATUS assignment
MATCH s {
WHEN SUCCESS: { ... }
WHEN PARTIAL_SUCCESS(msg, code): { ... } // (2) WHEN arm — binds message and code
WHEN FAILURE(msg, code): { ... }
}
IF s IS FAILURE THEN { ... } // (3) right side of an IS check in a plain IF
int32 n = SUCCESS // ERROR — STATUS literal outside its three places
Rationale. Confining the literals to three syntactic positions keeps STATUS out of general expression contexts, where it would read like an ordinary value. The only ways to inspect a STATUS are the guard (IS) and the exhaustive binder (MATCH).
(a.ii) Rule. An IF s IS FAILURE is a guard, suitable for early-exit propagation. Attaching an ELSE to a STATUS IS check is a compile error, because handling more than one variant is the job of MATCH, which is also the only way to bind the variant's message and code.
Example.
IF s IS FAILURE THEN { RETURN (s) } // guard — early-exit propagation
IF s IS FAILURE THEN { ... } ELSE { ... } // ERROR — no ELSE on a STATUS IS check
Rationale. A bare guard tests one variant and gets out of the way. The moment you want to branch on which variant, and to read its message and code, you are doing the work MATCH exists for. The language pushes you there rather than letting an IF/ELSE grow into a half-exhaustive match without binding.
(a.iii) Rule. PARTIAL_SUCCESS means the operation completed with caveats whose meaning is developer-defined and carried in the message; the caller decides whether, in its context, that counts as success or failure.
Example.
MATCH writeAll(records) {
WHEN SUCCESS: { done() }
WHEN PARTIAL_SUCCESS(msg, code): { retryRemainder(msg) } // caller decides this context's meaning
WHEN FAILURE(msg, code): { abort(msg) }
}
Rationale. Some operations genuinely land between clean success and outright failure. Rather than force the producer to pick one, PARTIAL_SUCCESS names that middle outcome and hands the interpretation to the caller, who alone knows whether a partial write is tolerable.
(b) Rule. A method communicates a value alongside an outcome through one of two return shapes. The comma shape, RETURNS (T a, STATUS s), populates every slot on every call: the value slot is meaningful even on failure. The pipe-XOR shape places a | separator in the clause and populates exactly one side — the success side or the failure side, never both. Its success side is a tuple of one or more typed slots, all populated together when the call succeeds. The shape is therefore either a success tuple — RETURNS (T t | STATUS s), RETURNS (A a, B b | STATUS s) — or the degenerate sole-STATUS RETURNS (STATUS s).
Example.
METHOD parse(String src) RETURNS (int32 pos, STATUS s) // comma — both slots always meaningful
METHOD dequeue() RETURNS (T t | STATUS s) // pipe-XOR — one side only
METHOD scan(String src) RETURNS (Token tok, int32 next | STATUS s) // pipe-XOR, multi-slot success tuple
METHOD flush() RETURNS (STATUS s) // degenerate — sole STATUS
Rationale. The comma shape suits a function such as a parser that returns the position it reached even when it did not fully succeed — the value is real regardless of outcome. The pipe-XOR shape suits everything where the value is meaningful only on success: it makes "there is no value on the failure side" a fact of the type rather than a convention the caller must remember.
(b.i) Rule. Three rules fix the pipe-XOR shape: the | appears at most once and is the last separator in the clause; STATUS is never a comma-member of a success tuple: it appears only as the post-| slot or as the sole slot, never alongside the values. An N-way union — (A a | B b | C c) — is not a V1 shape. It is reserved, and deferred to V3. The pipe-XOR shape is required for a reference-returning method that may fail (a reference has no meaningful default) and recommended for any value-returning method that should force the caller to confront failure.
Example.
METHOD lookupRef(K k) RETURNS (REFERENCE V v | STATUS s) // required — a reference has no default
METHOD at(int32 i) RETURNS (T t, STATUS s | STATUS s2) // ERROR — STATUS as a comma-member of success
METHOD choose() RETURNS (A a | B b | C c) // ERROR — N-way union reserved to V3
METHOD odd() RETURNS (T t | STATUS s | int32 n) // ERROR — `|` appears more than once
Rationale. One |, appearing last, keeps the split between "the values" and "the outcome" unambiguous — there is always exactly one success side and one failure side. Forbidding STATUS among the values prevents the two channels from bleeding together. The N-way union is genuinely useful but a much larger design, so it is parked to V3 rather than half-built in V1.
(c) Rule. A pipe-XOR call is consumed by an IF or a WHILE whose condition is the call. The IF's truth is the call's status — SUCCESS takes the THEN; FAILURE and PARTIAL_SUCCESS take the ELSE. Inside the success branch, $=[i] reads the i-th success slot ($=[0] first, $=[1] second), and bare $= is shorthand for $=[0]; the index must be a compile-time integer literal. Inside the failure branch, $! reads the failure STATUS's message (a String) and $# reads its int32 error code. Using a success or failure token outside its qualifying branch is diagnostic E6053.
Example.
IF scores->at(99) THEN {
REFERENCE int32 r =@ $= // $= is $=[0]
printline((r INTO String))
}
ELSE {
printline($!) // failure message (String)
printline(($# INTO String)) // failure code (int32)
}
IF parser->scan(source) THEN {
Token tok := $=[0]
int32 next := $=[1]
}
IF q->dequeue() THEN { ... } ELSE {
printline($=) // ERROR E6053 — success token in the failure branch
}
Rationale. Because the value exists only on success, the success tokens are reachable only inside the THEN, and the failure tokens only inside the ELSE. The compiler, not the reader, guarantees you cannot read a slot that was never populated. The index must be a compile-time literal because the slots are heterogeneously typed — the type of $=[i] is fixed only when i is known at compile time.
(c.i) Rule. Each of the three tokens has a verbose alias: $RETURNED for $=, $ERR_MESSAGE for $!, and $ERR_CODE for $#. Each alias shares token kind, scope rules, and lowering with its short form, so the two spellings are interchangeable and a call site may use whichever reads better.
Example.
IF parser->scan(source) THEN {
Token tok := $RETURNED[0] // $RETURNED ≡ $=
}
ELSE {
printline($ERR_MESSAGE) // $ERR_MESSAGE ≡ $!
printline(($ERR_CODE INTO String)) // $ERR_CODE ≡ $#
}
Rationale. The short forms keep dense call sites terse; the verbose aliases let a spelled-out call site read as prose. They lower identically, so choosing one is purely a readability call with no semantic cost.
(c.ii) Rule. The producer side is enforced so consumers need not re-check: E2081 for a pipe-XOR producer and E2082 for a single-return one guarantee every success slot is populated on the success branch, so a $=[i] consumer need not re-verify. A WHILE-pipe re-evaluates the call each iteration and exits on the first failure. Indexed access participates too: arr[i] on a collection whose subscript is pipe-XOR is itself a qualifying pipe-call condition.
Example.
WHILE q->dequeue() DO { Token t := $= } // re-evaluated each iteration; exits on first failure
IF arr[i] THEN { T v := $= } // arr[i] subscript is itself a qualifying pipe-call
Rationale. Enforcing full population at the producer (E2081/E2082) is what makes the consumer's job safe. Once the compiler proves every success slot is set on the success branch, reading $=[i] there can never see a hole, so the caller writes no defensive re-check. The WHILE-pipe turns "consume until it fails" into a loop header, and subscript-as-condition lets a fallible lookup drive a branch with no separate call.
(d) Rule. The multi-assign caller form generalizes to the multi-value success tuple: a destructuring assignment binds the success slots in order and then the STATUS.
Example.
Token tok, int32 next, STATUS s := parser->scan(source) // names every slot at once
Rationale. A caller may want all the slots as named locals, rather than reading them through $=[i] inside a branch. The destructuring form gives the same tuple its own names in one line, in slot order with the STATUS last.
(e) Rule. A return value is discarded deliberately and visibly with the marker $?: $? := call() drops a single return, and a $? slot drops one position of a multi-value return. Because $? marks an outbound value, it is a compile error as a call argument (E1136) or a parameter name (E1137). (Mandatory consumption — rejecting an ignored non-void return outright — is reserved and not yet enforced.)
Example.
$? := call() // drops a single return, on purpose and visibly
String data, $? := readFile(path) // keeps `data`, discards the STATUS deliberately
foo($?) // ERROR E1136 — $? as a call argument
METHOD m($? x) { ... } // ERROR E1137 — $? as a parameter name
Rationale. Discarding a result should be a decision the reader can see, not a silent drop: $? says "I looked at this and chose to ignore it." It is outbound-only because it marks a value leaving a call. Using it where a value is supplied — an argument or a parameter — is meaningless, so both are rejected.
Part I.M.ii — Unrecoverable failure: PANIC and RECOVER
(2026-06-26 rename: the surface keywords are PANIC (raise) and RECOVER (the TRY handler clause) — same mechanism, more fitting tone. TRY and FINALLY are unchanged. THROW/CATCH are retired keywords that now produce a "renamed to PANIC/RECOVER" compile error. This sits below the non-catchable assertion family (ENVZN_USER_GUIDE.md III.C): a PANIC is handleable and unwinds; an assertion failure aborts.)
(a) Rule. Error is a reserved abstract class, and every error type extends it. Error cannot be instantiated or raised directly, and it implements JSON_Serializer and Readable. It carries a message, set by the subclass through SUPER(msg); a cause, holding the message of a triggering error or EMPTY; and an int32 code. Three further fields are injected by the compiler at the panic site: filename, line_number, and threadName — the thread's variable name, or the absence value for an as-yet-unnamed context. A developer-defined error subclass extends Error, may add its own fields, sets message through SUPER, and assigns its other fields in INIT.
Example.
CLASS FileNotFoundError EXTENDS Error {
String path
INIT(String msg, String filePath) {
SUPER(msg) // sets the inherited `message`
path = filePath // own field set in INIT
}
}
Error e := CREATE Error("x") // ERROR — Error is abstract; extend it, don't instantiate
Rationale. Making Error abstract forces every raised error to be a named subclass carrying its own context, so a handler can match on type. The three injected site fields (filename, line_number, threadName) come free at the PANIC site because the compiler knows where the panic is — the developer never threads them through by hand.
(b) Rule. PANIC takes the error type and its developer-defined INIT arguments, and the compiler injects the site fields. Handling is structured as TRY { … } RECOVER (Type bind) { … } FINALLY { … }. The TRY block guards the risky work, and each RECOVER clause names one error type together with a binding for the caught instance. The parentheses around (Type bind) are required, and there is no as keyword. The named type must resolve to a known type — an unresolved name is E2103, not a downstream C++ error — and it must be an Error subclass; catching a class that does not extend Error is rejected (E4027). An error type declared in a dependency module is caught by its qualified Module::Type name (the same qualifier that names any other cross-module type), and the emitted catch is qualified to that module. The raise side is symmetric: PANIC Module::Type(...) accepts the same qualifier — a dependency's error type is raised by its qualified name, and the emitted construction and panic are qualified to that module.
Example.
TRY {
openConfig(path) // may PANIC FileNotFoundError
}
RECOVER (FileNotFoundError e) { // parens required; no `as`
printlineErr(e.message)
printlineErr(e.filename) // compiler-injected site field
}
Rationale. The (Type bind) form binds the caught instance to a name of the handler's choosing without borrowing a keyword like as; the required parentheses keep the type-plus-binding unit visually distinct from the block that follows.
(b.i) Rule. A TRY may carry several RECOVER clauses, matched top to bottom, so a subclass clause must precede its superclass to remain reachable. Catching the abstract base Error directly — RECOVER (Error e) — is rejected (E4016, catch-too-broad); every RECOVER clause must name a concrete Error subclass, and there is no catch-all.
Example.
TRY {
risky()
}
RECOVER (FileNotFoundError e) { // matched top to bottom by type
handleMissing(e)
}
RECOVER (NetworkError e) { // a different concrete Error subclass
handleNetwork(e)
}
Rationale. Top-to-bottom matching means an earlier, broader clause would shadow a later, narrower one; ordering subclass-before-superclass keeps every clause reachable. Catching the abstract base Error is rejected so a handler cannot silently swallow every failure type — each clause must name the concrete errors it is prepared to handle.
(b.ii) Rule. The trailing FINALLY { … } block — and the FINALLY keyword itself — is optional; when present it runs on every exit path, whether the TRY/RECOVER completes normally or a PANIC unwinds through it. Informative: catch errors as close to the problem as possible — the narrowest scope that can actually recover or add useful context — rather than a blanket handler far up the stack, where the originating context has already been lost.
Example.
TRY {
openConfig(path)
}
RECOVER (FileNotFoundError e) {
printlineErr(e.message)
}
FINALLY { // optional — omit the whole clause if unused
releaseHandle() // runs on normal exit AND on unwind
}
Rationale. FINALLY is the place for cleanup that must happen regardless of outcome, so it fires on both the normal and the unwinding path. Catching close to the problem preserves the context that a far-up blanket handler would already have lost.
(b.iii) Rule. On a PANIC, the runtime unwinds the stack between the panic and the matching RECOVER, running CLEANUP on every owned heap object in reverse order; non-owning handles are skipped, as ever, because the owner is responsible. Every error inherits a small set of methods from Error — ->stackTrace() returns the call stack as a Stack[String], ->hasCause() reports whether cause is present, ->getErrorCode() returns the code, and ->toJson() and ->toString() come from the two interfaces Error implements. Errors are chained by passing a triggering error's message as the cause of a new one.
Example.
TRY {
parseAll(source)
}
RECOVER (ParseError e) {
Stack[String] trace := e->stackTrace() // inherited from Error
IF e->hasCause() THEN { logCause(e) }
int32 c = e->getErrorCode()
PANIC ConfigError("bad config", e.message) // chain — triggering message becomes the new `cause`
}
Rationale. Unwinding in reverse and running CLEANUP on owned objects is what keeps PANIC memory-safe: every owner releases its heap on the way out, while non-owning handles are skipped because their owner already accounts for them. The inherited method set gives handlers a uniform way to inspect any error, and threading a message into cause builds the chain that explains what triggered what.
Section I.N — Modules and Linkage
The module as a filesystem folder: its manifest, its dependency and visibility rules, the name-resolution order, and how compiled modules link.
(a) Rule. A module is the unit of compilation, dependency, and visibility, and in Envzn it is a folder. There is no separate module declaration that the filesystem layout merely echoes: the folder is the module.
Example.
Networking/ // the folder IS the module "Networking"
Networking.json // the manifest (stem matches the module name)
src/
Socket.ev
HttpResponse.ev
interfaces.ev
enums.ev
Rationale. Making the folder the module means there is no import-resolution algorithm and no search path to learn: a module's structure is exactly what ls shows. This is one of the language's fixed principles.
(b) Rule. A module folder contains one manifest and one or more Envzn source files. The manifest is a JSON file named for the module: a module named Networking is described by Networking.json. It is the single source of truth for the module's identity, its build target, its Envzn dependencies, and any native dependencies. The compiler identifies the manifest by finding the *.json file whose stem matches its own name field.
Example.
{
"name": "Networking",
"version": "1.0.0",
"target": "shared_library",
"dependencies": [
{ "name": "AdvancedMath", "version": "1.0.0", "path": "../AdvancedMath" }
]
}
Rationale. The compiler keys on the name field rather than the filename, so the folder name itself carries no load. The identity lives in one authoritative place.
(b.i) Rule. Every file the compiler reads on a module's behalf lies inside the module folder — anything the manifest names, and anything the module's source directories contain. A path is canonicalized before use; if it resolves outside the module folder the compilation is rejected. The rule is about the resolved location, not the spelling: an upward ../ that lands back inside the folder is fine, and a plain-looking name that is a symlink out of it is not.
Example.
{ "name": "myLib", "version": "1.0.0", "target": "executable",
"nativeSources": ["../../../secret.cpp"] } // rejected: resolves outside myLib/
Rationale. The folder is the module (a), so a manifest deciding which files the compiler opens is bounded by that same folder — otherwise a manifest could steer a read anywhere on disk and route the contents into the translation unit or into a diagnostic. Canonicalizing first is what lets one rule cover both an upward escape and a planted symlink, without banning ../, which legitimate sibling layouts rely on. Paths that legitimately reach outside a module — a system library's headers, a system library to link — are the business of the build flags of (c.i), which are gated by provenance rather than by location.
(b.ii) Rule. A dependency declared with a path is bounded one level wider than (b.i), because a dependency legitimately lives outside the module that uses it. Its path is canonicalized and must resolve inside the consuming module, the library directory the build resolves against, or the project that library directory belongs to. Those roots are fixed by the build, never inferred from where the module happens to sit on disk. A dependency outside all three is not named by a path: it is installed, and resolves by name.
Example.
{ "name": "evTest", "version": "1.0.0", "path": "../../evTest" } // fine: a sibling in the project
{ "name": "Other", "version": "1.0.0", "path": "/etc" } // rejected: outside every root
Rationale. A dependency's path is the one manifest field whose whole purpose is to point outside the module, so (b.i)'s root cannot apply — but unbounded is not the alternative. Everything downstream of a resolved dependency path is a read: the dependency's manifest, its generated headers, its IR sidecar, and the include directories handed to the C++ toolchain. Bounding it to the project keeps the declaration a statement about the project rather than about the filesystem, and the package manager needs nothing wider: every location it searches for a dependency, the package cache included, lies within the project.
(c) Rule. The name follows the identifier rules of I.C and may not collide with a reserved kernel name. The target is either executable, which produces a binary and requires the entry-point arrangement of ENVZN_IR_SPEC.md C.vii, or shared_library, which produces a linkable library and a generated headers file.
Example.
{ "name": "Networking", "version": "1.0.0", "target": "shared_library" }
{ "name": "MyApp", "version": "1.0.0", "target": "executable" }
Rationale. The two targets split the two things a build can produce — a runnable binary versus a linkable library plus its public-surface headers — and each carries the arrangement its consumers need.
(c.i) Rule. The dependencies array names other modules, each by name, version, and an optional path; allows_foreign gates the foreign-function mechanisms; and a foreign block records native headers and libraries.
Example.
{
"name": "Networking",
"version": "1.0.0",
"target": "shared_library",
"allows_foreign": true,
"dependencies": [
{ "name": "AdvancedMath", "version": "1.0.0", "path": "../AdvancedMath" }
],
"foreign": {
"headers": ["sys/socket.h", "netdb.h", "openssl/ssl.h"]
}
}
Rationale. Every fact the build needs about a module's environment is declared in one place: what it depends on, whether it may reach across the foreign boundary, and which native headers and libraries it pulls in. The source files stay free of build metadata.
(c.ii) Rule. Every source file in a shared_library module begins with a MODULE declaration whose name must match the manifest; the declaration is optional for a single-folder executable.
Example.
MODULE Networking // first line of every Networking/src/*.ev
CLASS Socket {
...
}
Rationale. The MODULE line ties each source file back to the folder that owns it, so a file cannot be mis-filed into the wrong module. A single-folder executable has no ambiguity to resolve, so the line is optional there.
(c.iii) Rule. Dependency resolution locates each dependency's library and headers in a fixed order: the declared path, then a project lib/ directory, then a compiler-supplied search path. It resolves transitive dependencies topologically, and rejects a circular dependency or a version mismatch as an error.
Example.
"dependencies": [
{ "name": "AdvancedMath", "version": "1.0.0", "path": "../AdvancedMath" },
{ "name": "evTest", "version": "1.0.0", "path": "../evTest" }
]
// resolution order per entry: declared path -> project lib/ -> compiler search path
// a cycle A->B->A, or a version that does not match, is a compile error
Rationale. A fixed three-step lookup and a topological ordering make linkage deterministic and reproducible. Cycles and version mismatches are surfaced as errors rather than resolved silently, because either signals a broken dependency graph.
(d) Rule. Every declared type has a canonical fully-qualified name, modulename::TypeName. The :: operator is legal only between a module name and a type name, exactly one level deep; further access uses . or ->.
Example.
AdvancedMath::Matrix m := CREATE AdvancedMath::Matrix(3, 3)
m->transpose() // further access uses -> (or . )
AdvancedMath::Matrix::Row r // ERROR — :: is one level deep only
Rationale. Restricting :: to a single module-to-type hop keeps qualification unambiguous: it names where a type comes from, and nothing more. Member and method access is left entirely to . and ->.
(d.i) Rule. A bare, unqualified type name resolves in a fixed order: first a type in the current module, then a type in ENVZN. Otherwise it is a compile error, directing the developer to declare the source module and qualify the name. Bare lookup never consults a dependency, even a declared one: if two dependencies both expose a Foo, the developer always writes depA::Foo or depB::Foo.
Example.
String s = "hi" // bare: current module, else ENVZN (kernel)
Matrix m := CREATE Matrix(3, 3) // ERROR if Matrix lives in a dependency
AdvancedMath::Matrix m := CREATE AdvancedMath::Matrix(3, 3) // qualify the source
Rationale. Keeping bare lookup off dependencies means ambiguity — two dependencies each exposing Foo — is resolved at the source, by the developer writing depA::Foo or depB::Foo. No precedence rule is left for the reader to reconstruct.
(d.ii) Rule. ENVZN::X is always legal and reaches the kernel; because a module may not declare a type that shadows a kernel name (see (e)), the bare name and the qualified form never disagree.
Example.
Array[int32] a := CREATE Array[int32]() // bare Array == ENVZN::Array
ENVZN::Array[int32] b := CREATE ENVZN::Array[int32]() // same type, explicit
Rationale. Since kernel names cannot be shadowed, ENVZN::Array and bare Array are guaranteed to name the same type. The explicit form is available for clarity, but can never mean something different from the bare one.
(d.iii) Rule. A qualified name module::Type resolves in exactly one place: the named module. If that module declares no such type, it is a compile error — the compiler never falls back to searching the current module, the kernel, or another dependency for the bare Type. Qualification is a precise instruction about where the type comes from, and the compiler obeys it literally.
Example.
mda::TextColumn c := CREATE mda::TextColumn(vs) // resolves only in mda
mda::Widget w // ERROR — mda declares no Widget (no fallback to a local/kernel Widget)
Rationale. A qualified name that silently resolved elsewhere would defeat the purpose of qualifying. The developer wrote mda:: to pin the source, so an absent member must fail loudly rather than drift to a same-named type in another scope. This is the mirror of (d.i): a bare name never reaches a dependency, and a dependency-qualified name never reaches anything but that dependency. Together they make a module's own type win its bare name, while every cross-module reference stays explicit. A module may legitimately declare a TextColumn alongside a dependency's mda::TextColumn: the bare form is the module's, the qualified form the dependency's.
(d.iv) Rule. ALIAS — a file-scoped short name for a qualified entity. Repeating a module qualifier at every mention is noise when a file leans on one dependency type or namespace function; ALIAS removes it for the span of a single file. An ALIAS declaration sits at the very top of a .ev file, after the MODULE line and before any CLASS, INTERFACE, or NAMESPACE. It binds a short name to a fully-qualified target for that file only; another file in the same module is unaffected. Three forms are recognised, by their qualifier:
- ALIAS module::Type (::) binds the dependency type module::Type to the bare name Type; every bare Type in the file — in a declaration, a CREATE, a MATCH ... WHEN, a PANIC — resolves to module::Type.
- ALIAS Namespace.function (.) binds a single namespace function; a bare function(args) in the file becomes the namespace call Namespace.function(args), so the Namespace. qualifier is no longer written.
- ALIAS Namespace (a bare namespace name) imports every function of the namespace: each becomes bare-callable in the file (cubeRoot(x) for Math.cubeRoot(x)).
Resolution is a pure rewrite performed before the module's files are merged: each aliased mention is replaced by its fully-qualified form, so nothing downstream treats an aliased name specially. ALIAS never widens visibility or changes linkage — it is only shorthand for a name that the developer could have written in full, and it is subject to two guards. The target must be real: a module::Type that names no exported type, or a namespace/function that does not exist, is E2107. And the short name must be unambiguous. An ALIAS whose short name also names a type declared in the file, a method of the file's class, or another alias is E2108 — as is a whole-namespace import that brings in such a colliding name. A bare use could otherwise mean two things. ALIAS does not shadow; where a collision is possible the program is rejected rather than silently resolved.
Example.
MODULE report
ALIAS mda::TextColumn // a dependency type
ALIAS Math.cubeRoot // one namespace function
// ALIAS Math // (alt.) every Math function, bare-callable
CLASS Main IMPLEMENTS TaskStarter {
METHOD start() RETURNS STATUS {
TextColumn c := CREATE TextColumn(vs) // resolves to mda::TextColumn
float64 r = cubeRoot(27.0) // resolves to Math.cubeRoot(27.0)
RETURN (SUCCESS)
}
}
Rationale. The qualifier exists to pin a name's source (d.iii); ALIAS keeps that pinning while paying for it once, at the top of the file, instead of at every use. Confining it to the file and forbidding any collision preserves the guarantees of (d.i)–(d.iii): a reader still sees exactly one meaning for each bare name. The alias table sits where the file's external dependencies are already declared, so what a file imports stays legible in one place. Because the alias is resolved by rewriting to the fully-qualified form before analysis, it introduces no new resolution scope and no runtime construct. It is a source convenience the compiler erases early.
(e) Rule. Type naming against the kernel is governed by one rule: no kernel type name may be shadowed. Every type the kernel (ENVZN) declares at top level is reserved: the utility gateways System, Stdio, Process, Math, File, Path, and the retired-but-still-reserved Convert, together with the collection and value types String, Array, Set, Dictionary, Stack, Queue, Box, DynamicString, and the rest. A module that declares a class, struct, interface, group, or enum with one of those names is a compile error.
Example.
CLASS Set { ... } // ERROR — Set is a reserved kernel top-level name
CLASS Path { ... } // ERROR — Path is a reserved kernel gateway name
CLASS Convert { ... } // ERROR — retired but still reserved
CLASS SocketPool { ... } // OK — not a kernel name
Rationale. An earlier design split these — gateways were reserved but collection and value types were shadowable (a module's own Set bound bare Set locally while ENVZN::Set reached the kernel). Shadowing is unsafe because it silently corrupts type resolution. A concrete field in a shadowing class can be mis-typed as the shadowed kernel generic's type parameter T, so the emitter lowers it as the generic (_ev_ref_of(...)) instead of the concrete form (&this->field). The distinction was removed; all kernel names are now reserved alike.
(e.i) Rule. The check keys on the kernel's top-level types only. A class-nested type, such as the GROUP StatusKind inside _EvStatus, does not reserve its bare name, so a module may still declare its own top-level StatusKind.
Example.
STRUCT StatusKind { int32 x } // OK — _EvStatus.StatusKind is nested, not top-level
Note. The name is StatusKind rather than the broader Kind, which is plausible enough as a user type that a module declaring one would collide with the nested kernel group. Narrowing the kernel name removed that reachable collision.
Rationale. Reserving only top-level kernel names keeps the reserved set small and predictable. A name buried inside a kernel class was never reachable bare, so reserving it would cost developers names for no safety gain.
(f) Rule. Visibility is public by default: every type a module declares is visible to any module that depends on it. The generated headers file is the authoritative record of that public surface. INTERNAL marks a declaration module-private, omitted from the headers and invisible to dependents. INTERNAL applies to concrete classes, enums, structs, fields, and methods. On a field or method it widens the audience to every class in the same module while keeping it out of the public surface, and it is mutually exclusive with PRIVATE.
Example.
INTERNAL CLASS JsonFrame IMPLEMENTS Cloneable { // module-private, not in headers
INTERNAL int32 depth // visible to same-module classes
INTERNAL METHOD push() RETURNS STATUS { ... }
}
Rationale. Public-by-default matches the folder-is-the-module model: a module's declared surface is its interface. INTERNAL gives a bounded relaxation, opening a member to the rest of the module without ever leaking it into the headers. PRIVATE (class-only) and INTERNAL (module-wide) are genuinely distinct audiences, and cannot be combined.
(f.i) Rule. Applying INTERNAL to an interface, an abstract class, or a local variable is a compile error.
Example.
INTERNAL INTERFACE Hasher { ... } // ERROR — an interface exists to be implemented
INTERNAL ABSTRACT CLASS Shape { ... } // ERROR — an abstract class exists to be extended
METHOD run() {
INTERNAL int32 count = 0 // ERROR — a local has no module-level visibility
}
Rationale. An interface and an abstract class exist precisely to be implemented or extended by a consumer, so hiding them from consumers is contradictory. A local variable has no module-level visibility to adjust, so the modifier is meaningless there.
(g) Rule. A module's source files follow a strict naming rule. Every file other than the grouping files holds exactly one top-level declaration, and its filename stem matches that declaration's name. The declaration is a class, an interface, a primitive, a conversions block, or a single STRUCT. The grouping files are interfaces.ev, which holds the module's INTERFACE and GROUP declarations; enums.ev, which holds its ENUM declarations; and structs.ev, which holds module-level STRUCT declarations.
Example.
Networking/src/
Socket.ev // one top-level: CLASS Socket
HttpResponse.ev // one top-level: CLASS HttpResponse
interfaces.ev // all INTERFACE + GROUP declarations
enums.ev // all ENUM declarations
structs.ev // module-level STRUCT declarations
Rationale. Keeping one declaration per file with a matching stem keeps a module's file inventory in close correspondence with its declared surface: the file list is the type list. The three grouping files pool the small declarations that would otherwise scatter across many one-line files.
(g.i) Rule. A STRUCT is the one supporting type with two valid homes. The structs.ev pool is the natural place for small, shared, or owner-less records; its own StructName.ev file reads better when a struct is closely tied to a single type and meant to live beside it. A STRUCT may not share a file with another top-level declaration (E9008); ENUM and GROUP have no own-file form and always live in their grouping file.
Example.
// AddrInfo.ev — own-file form, sits beside the type that uses it
STRUCT AddrInfo { int32 family, int32 socktype, int32 protocol }
// structs.ev — the pooled form for small shared records
STRUCT Point { int32 x, int32 y }
STRUCT Range { int32 lo, int32 hi }
// Socket.ev
CLASS Socket { ... }
STRUCT Handle { int32 fd } // ERROR E9008 — a STRUCT may not share a file
Rationale. A STRUCT earns two homes because both readings are legitimate: pooled when it is a small shared record, own-file when it is bound to one type. It may never share a file (E9008), which would break the one-declaration-per-file correspondence. ENUM/GROUP have no own-file form and always live in their grouping file.
(h) Rule. Envzn reaches a C function or C library through FOREIGN BIND: a forward declaration of an external function, written entirely in Envzn types. From it the compiler emits a direct call and synthesizes every conversion at the boundary, so .ev source contains no inline C++ and the foreign surface stays type-checked in Envzn terms. A module uses any foreign mechanism only if its manifest sets allows_foreign.
Example.
FOREIGN BIND CONSTANT int32 SOCK_STREAM
FOREIGN BIND gai_strerror(int32 errcode) RETURNS String
FOREIGN BIND freeaddrinfo(AddrInfo res) // (LOAD out-parameters are covered in (h.i))
{ "name": "Networking", "allows_foreign": true, "foreign": { "headers": ["netdb.h"] } }
Rationale. Writing the foreign surface entirely in Envzn types keeps .ev source free of inline C++, and lets the type checker cover the boundary. The allows_foreign manifest gate keeps the foreign-function boundary out of ordinary application code. The full account of how FOREIGN BIND and its relatives lower to C is given in ENVZN_IR_SPEC.md C.vi, and inline FOREIGN { } blocks are removed (h.ii). What remains pending is the deprecation of the legacy FOREIGN declaration form, and the _cxx* retirement.
(h.i) Rule. The LOAD parameter modifier — out-parameters the C call fills. LOAD marks a FOREIGN BIND parameter the C function writes through rather than reads. It lowers to &handle uniformly, and the parameter's storage shape alone sets the pointer depth. Three shapes cover the cases. A LOAD char8[], or any value-array, is a caller-sized fill buffer: it lowers to a raw element pointer (void *) that the C call writes into and whose count it returns. A LOAD of a FOREIGN_TYPE value handle is a caller-allocated struct, passed as &value → X *, its fields reached with .. A LOAD of a FOREIGN_TYPE pointer handle is a callee-allocated struct, passed as &pointer → X **, its fields reached with ->. LOAD is legal only on a FOREIGN BIND parameter; on an ordinary method parameter it is E2100. It is legal only on an eligible mutable storage type, so a LOAD of an immutable String is E2101. The three shapes LOAD accepts are given in (h.iii); their lowering is ENVZN_IR_SPEC.md C.vi.
Example.
FOREIGN BIND read(int32 fd, LOAD char8[] data, int32 n) RETURNS int32 // fill-buffer: void*, C writes, returns count
FOREIGN BIND stat(String path, LOAD FileStat buf) RETURNS int32 SETS_ERRNO(-1) // caller-allocated struct: &value -> X*
FOREIGN BIND getaddrinfo(String node, String service,
AddrInfo hints, LOAD AddrInfo res) RETURNS int32 // callee-allocated: &pointer -> X**
// METHOD take(LOAD int32 x) // ERROR E2100 — LOAD outside a FOREIGN BIND
// FOREIGN BIND f(LOAD String s) // ERROR E2101 — immutable String ineligible
Rationale. LOAD is the boundary's answer to a C out-parameter — a value the callee fills — without ever exposing a raw pointer to Envzn code. The compiler takes the address, and the storage shape alone decides whether that is a void * fill buffer, an X * to a caller-allocated struct, or an X ** for a callee-allocated one. No separate OUT or OUT STRUCT keyword is needed. It is the write-through half of the FFI surface that the UNSAFE / opaque carriers of I.I.viii complete on the handle-holding side.
(h.ii) Rule. No inline FOREIGN { } blocks. An inline FOREIGN { <raw C++> } block embedded in .ev source is not a valid construct. Native code is reached only through FOREIGN BIND, against a separately-compiled shim. A block in any module — the ENVZN kernel included — is diagnostic E3041. The prohibition is universal (there is no allows_foreign escape — allows_foreign gates the FOREIGN BIND family, never a raw-C++ block) and is enforced in the analyzer, so it holds under every backend. The kernel's own Process singleton reaches fork / execvp / waitpid this way, over the C shim EV_process_native.c.
Example.
// METHOD m() { FOREIGN { ::some_c_call(); } } // ERROR E3041 — inline raw-C++ block removed
FOREIGN BIND some_c_call(...) RETURNS ... // reach native code through a bound shim
Rationale. An inline block was the one native passthrough that bypassed the allows_foreign opt-in — any .ev could inject arbitrary C++ (or machine code) with no manifest flag and no diagnostic. Removing it rather than gating it closes that hole completely, and makes (h)'s "no inline C++" a genuine guarantee rather than an aspiration. The native escape remains available, typed and greppable, through FOREIGN BIND. (Shipped V1, 2026-07-20.)
(h.iii) Rule. The LOAD shape table. LOAD accepts exactly three parameter shapes. The presence or absence of a * in the bound C type distinguishes the second from the third: a caller-allocated value from a callee-allocated pointer.
| Form | C shape | What the compiler passes | Field access |
|---|---|---|---|
LOAD char8[] |
void *buf — a caller-sized fill buffer |
the raw element pointer; the C call writes into the Envzn-owned array and returns the count | — |
LOAD <FOREIGN_TYPE value> — bound as (struct X) |
struct X *buf — caller allocates |
the handle is a stack value; LOAD passes &value, giving X* |
. |
LOAD <FOREIGN_TYPE pointer> — bound as (struct X *) |
struct X **out — callee allocates |
the handle is a pointer; LOAD passes &pointer, giving X** |
-> |
Example.
FOREIGN_TYPE BIND (struct stat) FileStat { int64 st_size int32 st_mode int64 st_mtime }
FOREIGN BIND stat(String path, LOAD FileStat buf) RETURNS int32 SETS_ERRNO(-1) // value form — buf becomes X*
FOREIGN_TYPE BIND (struct addrinfo *) AddrInfo // pointer form — the callee allocates the list node
Rationale. Reading the value-versus-pointer distinction off the bound C type means both POSIX families are expressible without a separate OUT modifier: the caller-allocated stat and its relatives, and the callee-allocated getaddrinfo. The declaration stays a transcription of the C signature rather than a second thing to learn.
(h.iv) Rule. The CALLED_BY_EMITTER marker — a binding whose caller is generated code. A FOREIGN BIND whose only call sites are written by the compiler, not by any .ev source, is marked CALLED_BY_EMITTER. It is a trailing marker on the declaration, in the same position and of the same kind as SETS_ERRNO, may appear at most once, and — like the other markers — is valid only on a FOREIGN BIND, never on the legacy FOREIGN form (E1169; a repeat is E1170). It changes no lowering: the binding is emitted, typed, and called exactly as an unmarked one. Its sole effect is to exempt the declaration from the unused-FOREIGN warning, which counts FOREIGN::name(...) call sites in the declaring file and therefore cannot observe a caller the compiler synthesizes. The marker is kernel-facing in practice; an ordinary module has no emitter-reached bindings.
Example.
// The emitter writes this call into every synthesized bounds check;
// no .ev line names it.
FOREIGN BIND _value_array_throw_oob(int32 idx, int32 bound) CALLED_BY_EMITTER
Rationale. Without the marker the compiler reports a still-needed declaration as dead and advises removing it — advice that breaks the build, because the emitted C++ still calls the symbol. The alternative, teaching the analyzer the set of names the emitter happens to synthesize, moves a fact about the kernel into the compiler and covers new cases silently. Declaring the intent where the binding is written keeps the analyzer honest about everything else in the file: the warning stays live for every unmarked declaration, which is how the redundant ev_datetime_from_broken_down in DateTime.ev was found.
Section I.O — The Standard Library Surface
The surface of the kernel module that every Envzn program may use without declaring a dependency — a catalog that points at where each member is specified.
(a) Rule. The standard library is the surface of the kernel — the ENVZN module — that an Envzn program may use without declaring a dependency. Some of its members are specified elsewhere in the type system: String in I.D.vii, the AS/INTO conversion operators' governing principle in I.D.viii, and the array and collection family in I.J; this section catalogues the remainder — the system gateways and the kernel type hierarchy. The precise, current method signatures of these classes live in the kernel source files. Where this section and a kernel .ev file disagree about a signature, the kernel file is the authority.
Example.
printline("no import needed") // ENVZN surface — no dependency declared
String s := "hello" // String — specified in I.D.vii
int32 n := small INTO int64 // AS/INTO conversion — governed by I.D.viii
Rationale. The intent is to fix what surface exists and what shape it has, not to duplicate a signature list that a later kernel change would silently make stale. The kernel .ev file, not this prose, is the tie-breaker on any exact signature.
(b) Rule. File and Path cover filesystem work. A File is constructed from a path and works in either of two modes without the caller choosing between two types: adaptive, where each operation opens, acts and closes, or persistent, where an explicit open holds a handle across many operations until an explicit close. It carries a text surface that reads and writes whole-file String content, a byte surface that is the Readable/Writable contract, positioned reads and writes that act at an offset without disturbing the cursor, and advisory whole-file locking for cooperating processes. It deliberately carries no metadata — whether a path names a directory, its modification time — because that belongs to Path (b.i), and a File and a Path over the same string are two views of one thing rather than a hierarchy. A file operation can always fail for reasons outside the program — a missing file, a permission denied — so its reads and writes report failure through the recoverable-error vocabulary of I.M rather than returning a bare value.
Example.
File f := CREATE File("notes.txt")
IF f->readText() THEN { String contents := $= } // pipe-XOR read (I.M)
ELSE { printlineErr($!) } // $! = failure message
Rationale. External failure is not a defect in the program, so it belongs in the recoverable STATUS/pipe-XOR vocabulary of I.M rather than a PANIC. A caller must handle a missing file inline rather than unwind.
(b.i) Rule. A Path constructed from a path string answers structural questions about it: whether it exists, and whether it names a directory or a file. It derives related paths — the parent, the basename, the stem, the extension, a path with a replaced extension, a joined segment, a resolved absolute form — and lists a directory's contents.
Example.
Path p := CREATE Path("/tmp/data.csv")
IF p->exists() THEN { ... }
String ext := p->extension() // "csv" — no leading dot
String parent := p->parent()
Path joined := p->join("sub")
Rationale. Path answers questions and derives new paths as pure structural operations; the extension is returned dot-free ("csv", not ".csv") so callers compare and rebuild extensions without stripping punctuation.
(b.ii) Rule. Process runs an external program and returns its exit code together with its captured standard output and error.
Example.
ProcessResult r = Process->run("ls", args)
Rationale. Exit code plus captured stdout/stderr is the complete result of running a child process; bundling all three means the caller never re-reads the process's streams separately.
(c) Rule. Math is a NAMESPACE of numeric functions and constants — a stateless free-function host, not an actor. Its constants and functions are both reached with .: Math.PI, Math.E, Math.sqrt(x), Math.floor(y). The surface covers absolute value and sign across the numeric types. It covers square root, cube root, and the power, exponential, and logarithm family, together with the trigonometric and hyperbolic functions, taking angles in radians. It covers the rounding family of floor, ceiling, round, and truncate; minimum, maximum, and clamp; and radian-to-degree conversion. And it covers the overflow-aware arithmetic forms — wrapping, saturating, and a checked form returning a STATUS — together with the distance function hypot.
Example.
float64 c := Math.PI // constant — reached with '.'
float64 e := Math.E
float64 r := Math.sqrt(2.0) // function — also '.', never Math->sqrt
float64 f := Math.floor(y)
float64 d := Math.hypot(3.0, 4.0) // distance function
float64 m := Math.clamp(v, 0.0, 1.0)
Rationale. Math holds no state and reaches nothing, so it is a NAMESPACE, reached with ., rather than a SINGLETON actor reached with ->. Writing Math->sqrt(x) is the E1106 error of the six reflexes.
(c.i) Rule. A Math function whose mathematical domain a runtime argument can violate — a square root of a negative, a logarithm of a non-positive — panics with MathError. A domain violation the compiler can detect statically is a compile error.
Example.
TRY { float64 r := Math.sqrt(x) } // x from runtime — may panic
RECOVER (MathError e) { printlineErr("domain error") }
Math.sqrt(-1.0) // ERROR — negative literal, domain violation caught statically
Rationale. A domain violation the compiler can prove is a programmer error surfaced early; one that depends on runtime data is a recoverable defect, so it is the catchable MathError (PANIC/RECOVER tier), letting the caller recover close to the failure.
(d) Rule. Type conversion is not a singleton but the AS/INTO operator surface of I.D.viii, under that same principle. A widening conversion is total and lossless (INTO); a narrowing or sign-crossing one is lossy and range-checked (AS); and a parse, which can always fail, is the fallible INTO from a String.
Example.
int64 wide := small INTO int64 // widening — total, lossless
IF big AS int32 THEN { int32 n := $= } // narrowing — fallible, range-checked
IF "42" INTO int32 THEN { int32 v := $= } // parse from String — fallible INTO
ELSE { printlineErr("not a number") }
Rationale. The operator is the guarantee: INTO promises no loss, AS admits loss and forces a range check, and a parse rides the fallible INTO because a String may not name a value. The reader knows the safety of a conversion from the operator alone.
(d.i) Rule. The conversion families the retired Convert singleton once carried are now CONVERSIONS hosts of operators: value-to-String rendering on NumberConverter / FloatConverter / CharConverter, numeric widen/narrow on PrimitiveConversions, char-width crossing on CharWidthConverter, and the String↔ByteBuffer bridge on TextConverter.
Example.
String s := 42 INTO String // rendered via NumberConverter
String t := 3.14 INTO String // via FloatConverter
ByteBuffer bb := s INTO ByteBuffer // String -> ByteBuffer bridge (TextConverter)
Rationale. Grouping each conversion family on a CONVERSIONS host keeps the developer surface a single pair of operators (AS/INTO) while relocating the per-family machinery inward — the cost lives in the host, not on the reader's line.
(d.ii) Rule. The operations that are deliberately not checked conversions remain ordinary NAMESPACE method calls: the bit-exact truncating/reinterpreting casts on NumericUtilities, the HexCodec and Base64Codec codecs, the ByteOrderCodec endian serialization, and the CharClassifier character predicates.
Example.
String hex := HexCodec.toHex(bb) // codec — NAMESPACE call with '.'
IF Base64Codec.fromBase64(s) THEN { ByteBuffer back := $= } // fallible — pipe-XOR
boolean isDigit := CharClassifier.isDigit(c)
Rationale. A bit-exact reinterpret or a hex/base64 round-trip is not a value-preservation question the AS/INTO safety contract speaks to, so those stay plain NAMESPACE calls (.) rather than pretending to be checked conversions.
(d.iii) Rule. System is the SINGLETON CLASS of process-level services — getenv for reading the environment, and exit for terminating with a chosen code; inheriting from System is forbidden, since it is a singleton.
Example.
IF System->getenv("HOME") THEN { String home := $= }
System->exit(0) // message send — '->', not '.'
Rationale. Process-level services are a single shared actor, so System is a SINGLETON reached with ->; a singleton has exactly one instance, which is why subclassing it is rejected.
(d.iv) Rule. The standard-input/standard-output surface is the separate Stdio singleton, and its text-writing methods are the one part of the kernel callable unqualified: print, printline, printErr, printlineErr, formatPrint, and formatPrintline are written bare — printline("ready"), not Stdio->printline("ready") — because printing is frequent enough that the receiver is noise. The rest of the surface is reached through Stdio-> in the ordinary way. print and printline write a String to stdout (printline appending a newline); formatPrint and formatPrintline take a template and arguments under the rules of I.F.vi; printBytes writes a ByteBuffer verbatim; and the printErr family — printErr, printlineErr, formatPrintErr, formatPrintlineErr, printBytesErr — writes the same shapes to stderr. The input side is Stdio->read(length) for raw bytes paired with an end-of-file boolean, and Stdio->readline() for a single line paired with the same boolean.
Example.
print("no newline")
printline("with newline")
formatPrintline("x=$1 y=$2", x, y) // template — rules of I.F.vi
Stdio->printBytes(bb) // ByteBuffer written verbatim
printlineErr("to stderr") // printErr family -> stderr
ByteBuffer chunk, boolean eof := Stdio->read(1024)
String line, boolean eof2 := Stdio->readline()
Rationale. stdin/stdout is a distinct concern from process control, so it is its own Stdio singleton; pairing each read with an end-of-file boolean lets the caller detect stream end without a sentinel value.
(d.v) Rule. Every Stdio output method returns the pipe-XOR shape of I.M.i — int32 written | STATUS status — because a write to a terminal or a redirected stream can fail. Text composition itself is the Formatter class of I.F.vi.
Example.
IF printline("hi") THEN { int32 written := $= } // int32 written | STATUS status
ELSE { printlineErr($!) } // write failed — report on stderr
Rationale. A pipe to a closed terminal or a full disk can reject a write, so an output cannot promise a bare value. The pipe-XOR of I.M.i forces the caller to acknowledge either the byte count or the failure, never to ignore it silently.
(e) Rule. The kernel type hierarchy for failure is rooted at Error, the reserved abstract class of I.M, with the concrete subclasses the kernel itself raises — among them CompileError, FileError, MathError, IndexOutOfBoundsError, and NetworkError. STATUS, with its SUCCESS, PARTIAL_SUCCESS, and FAILURE variants, is the recoverable-failure type.
Example.
TRY { ... }
RECOVER (FileError e) { ... } // concrete subclass of Error
RECOVER (NetworkError e) { ... } // another concrete subclass (base Error is E4016)
STATUS s = FAILURE("bad input") // recoverable — assigned with '='
IF s IS FAILURE { ... }
Rationale. Two separate failure vocabularies (I.M): Error and its subclasses are the handleable-defect (PANIC/RECOVER) tier, while STATUS is the recoverable tier — keeping "external failure I recover from" distinct from "defect I unwind on."
(e.i) Rule. Two interfaces support hashing: Hashable, which a type implements to produce its own hash and so become usable as a Dictionary key or a Set element. And Hasher, the pluggable hashing strategy a Dictionary[K, V, H] or Set[V, H] is parameterized over; the shipped default is DefaultHasher.
Example.
Dictionary[String, int32, DefaultHasher[String]]
m := CREATE ChainedHashDictionary[String, int32, DefaultHasher[String]]() // H stated explicitly
Set[String] seen := CREATE Set[String]()
Rationale. Separating Hashable (a type hashes itself) from Hasher (a strategy the collection is parameterized over) lets one keyed type serve many hashing policies — the DoS-resistant SipHash strategy plugs into the H slot without changing the key type.
(e.ii) Rule. The kernel's interfaces complete the surface: Cloneable, the two iterator interfaces, the four operator interfaces of I.K.iv, Hashable, Readable, Printable, Serializable, JSON_Serializer, and ThreadStarter. Its enums include DeliveryMode, Severity, Build, Encoding, and Endianness. The Appendix catalogues them in full.
Rationale. This is the full interface/enum roster of the kernel surface; the exhaustive catalogue lives in the Appendix, so this paragraph names the members and defers their detail rather than duplicating the list.
(f) Rule. Several kernel subsystems shipped after the survey above was first written and carry their own surface; rather than restate them, this section names them and fixes what surface exists; their exact signatures are governed by (a). They are: the date-and-time ring — DateTime, TimeDuration, and the TimeComparison enum, with DateTimeFactory as the construction singleton and the ring's own formatting and parsing; MeasuringTimer for elapsed-time measurement; and the Random family — the Random, Shuffler, and Shuffleable interfaces over the Xoshiro256 engine, with the CasualRandom, SeededRandom, and SecureRandom generators and RandomError. Logger, once sketched as a kernel member, is now the standalone Logger module and is no longer part of this surface. Each named here is a shipped V1 member.
Example.
DateTime d := DateTimeFactory->newDateTime(2026, 7, 28, 9, 0, 0) // construction singleton
MeasuringTimer t := CREATE MeasuringTimer()
Random r := CREATE CasualRandom()
Rationale. Naming the subsystems keeps this catalog complete without duplicating a signature list that a later kernel change would silently make stale — the same policy (a) states for the rest of the section.
(g) Rule. BuildInfo is the kernel NAMESPACE through which a program reads its own build provenance at runtime — the data a --version line would print. Its members are reached with ., and the text-valued ones return the byte-array type rather than String: BuildInfo.version() yields the full version line (the MAJOR.MINOR.PATCH+⟨counter⟩ of paragraph (h)), BuildInfo.gitDescribe() the git describe of the build commit, BuildInfo.commit() and BuildInfo.branch() the commit identifier and branch, and BuildInfo.builtAt() the ISO-8601 build timestamp — each a char8[] of UTF-8 bytes. The two non-text members are BuildInfo.isDirty(), a boolean true when the build was taken from a working tree carrying uncommitted changes, and BuildInfo.buildCounter(), the uint64 build counter.
Example.
char8[] v := BuildInfo.version() // "MAJOR.MINOR.PATCH+⟨counter⟩" as UTF-8 bytes
char8[] gd := BuildInfo.gitDescribe()
char8[] br := BuildInfo.branch()
boolean dirty := BuildInfo.isDirty() // uncommitted working tree?
uint64 cnt := BuildInfo.buildCounter()
printline(BuildInfo.version() INTO String) // char8[] -> String bridge (I.D.viii)
Rationale. The byte-array choice is deliberate and structural. The provenance surface is wired into the kernel build ahead of String, so it deals in char8[] and leaves the conversion to text to the caller, through the bridge of I.D.viii. None of these values is authored in source; they are computed at build time and surfaced to the namespace through a compiler-generated header, under the model of paragraph (h).
(h) Rule. Build provenance follows one model across every component — the kernel, the compiler, and each module. A component's version is MAJOR.MINOR.PATCH+⟨counter⟩. The MAJOR.MINOR is curated in the component's manifest, and the PATCH is derived from version control as the number of commits since the nearest v⟨MAJOR⟩.⟨MINOR⟩ tag. The trailing +⟨counter⟩ is a hexadecimal build counter, sourced in order from the EV_BUILD_NUMBER environment variable, then GITHUB_RUN_NUMBER, then a local per-checkout count.
Example.
1.4.7+3af // MAJOR.MINOR.PATCH+⟨counter⟩
// MAJOR.MINOR = manifest PATCH = commits since v1.4 tag
// counter precedence: EV_BUILD_NUMBER -> GITHUB_RUN_NUMBER -> local per-checkout count
Rationale. Splitting the version into curated (MAJOR.MINOR), version-control-derived (PATCH), and build-derived (+⟨counter⟩) parts means only the intentional part is hand-authored; the rest is computed, so a version cannot drift from the tree it was built from.
(h.i) Rule. A manifest may declare a groups map: named sets of the component's source files that work together, such as the kernel's concurrency or collections families. The version is then reported at three granularities — the whole component, each subgroup, and each file — with the finer PATCHes derived the same way over the narrower path set.
Rationale. A groups map lets a large component report provenance at the granularity that matters — the whole kernel, its collections family, or a single file — each PATCH counted over exactly its own path set.
(h.ii) Rule. The full provenance is written on every successful build to the emitted output: the version, the git describe, the commit, the branch, the dirty flag, the counter, and the build timestamp. It lands as a buildinfo.json beside the artifacts, and as a generated header the kernel build links in to back the BuildInfo surface of paragraph (g). It is never written back into tracked source. (Shipped V1, 2026-06-22.)
Rationale. That boundary is the governing principle. A source tree records what a program is, not the circumstances of any one build of it, so build identity lives on the output side — buildinfo.json and the generated header — where it cannot churn the source.
Section I.P — Serialization and Reconstruction
How a value crosses the boundary between a program and a document. The format-neutral value model, the derive-by-use codec the compiler synthesizes for a named type, the RESTORE hook that seals a reconstructed class, and the DERIVED field that never travels.
(a) Rule. Serialization in Envzn is declarative and derived, never hand-written and never reflective. A type does not implement a codec; it is used at a serialization site, and the compiler synthesizes the codec for it. There is no opt-in marker on the type, no runtime type table, and no field-walking loop a developer writes. The machinery is the compiler's — the surface is one line.
Example.
String text := person->toJson() // serialize — codec synthesized for Person
IF Json::Codec.parse[Person](text) THEN { // reconstruct — the same, in reverse
Person p := $=
}
Rationale. This is the "relocate the cost inward" tiebreak of I.A applied to the most tedious, most error-prone code a developer writes. Reflection would move the cost to runtime — a per-object tax, and errors a developer meets only by hitting them. Deriving at compile time gives the same surface with none of the cost: the errors are diagnostics, and the codec is ordinary code the memory-safety proof of I.I covers like any other.
(b) Rule. The value model is format-neutral and lives in the kernel; a format lives in a module. DataValue is the neutral tree: the shape shared by JSON, BSON, CBOR, and a database row. Three interfaces are the contract every format implements — Serializer, a write sink receiving a flat event stream; Deserializer, a source that re-emits its content into a sink; and Serializable, a type that can describe itself to a sink. A reconstruct event stream is a serialize event stream, so Serializer is the one universal sink; there is no separate visitor abstraction.
Rationale. Putting the value model in the zero-dependency kernel, while keeping the text of JSON in its own module, lets one derived codec serve JSON, a database row, and any later format. The kernel never learns a format.
(c) Rule. A STRUCT is pure data and is reconstructed by direct field injection. A CLASS may carry an invariant or a computed field, so it is reconstructed by injecting the persisted fields and then calling RESTORE — an ordinary MODIFY METHOD RESTORE() RETURNS STATUS, declared by the class itself. RESTORE runs on the freshly-injected, not-yet-sealed instance: it computes the DERIVED fields and validates the type's invariants. It is fallible — a broken invariant arriving from a document is an expected boundary failure, not a defect — so it returns a FAILURE which the synthesized parse propagates into its | STATUS arm, and it never PANICs. A CLASS reconstructed at a parse site that declares no RESTORE is diagnostic E6100, reported at the use site.
Example.
CLASS Person {
PRIVATE String name
PRIVATE number age
DERIVED String display // computed, never in the document
MODIFY METHOD RESTORE() RETURNS STATUS { // the reconstruct hook
IF (.age < 0) { RETURN (FAILURE("Person: age must be >= 0")) }
.display := ("$1 ($2)")->format(.name, .age)
RETURN (SUCCESS)
}
}
Rationale. The STRUCT/CLASS distinction already means data versus data and behavior, and reconstruction is exactly where that distinction earns its keep: a record can be poured, an object must be sealed. Making RESTORE mandatory for a reconstructed class is the point rather than a burden: "this type crosses a deserialization boundary" becomes a contract the compiler enforces. And because RESTORE is an ordinary method, INIT calls the same one — so validation cannot be written twice, or forgotten once.
(d) Rule. A DERIVED field is a computed field — a memoized hash, a name assembled from two others — and it is the one place a field-level annotation is unavoidable. It is used symmetrically: skipped on serialize, and computed by RESTORE on reconstruct. It is writable during the injection-and-RESTORE window and frozen after. DERIVED on a STRUCT field, or on a CLASS that declares no RESTORE, is diagnostic E6106 — in either case there is nothing to compute it.
Rationale. A computed field is not data; round-tripping it would persist a value the type can always rebuild, and a stale one would then override the truth on the way back in. Marking it once, and having the one hook that computes it also be the one hook that validates, keeps the two halves from drifting apart.
(e) Rule. Synthesis is recursive and post-order over the type graph reachable from the use site: a nested owned value's codec is synthesized first, and a nested value is RESTOREd — and therefore valid — before its owner's RESTORE runs. Type-graph cycles terminate by memoization. A field whose type cannot be serialized is diagnostic E6101, named against the offending field and type.
Rationale. Post-order is what makes the hook's contract honest: when a type validates its invariants, everything it owns has already validated its own.
(f) Rule. Three shapes are deferred in V1 and are diagnosed rather than half-supported. A nested field of interface or abstract type is open polymorphism, which needs a discriminator and a runtime registry (E6102). A REFERENCE field is a back-pointer into an object graph, and a hook cannot repair a graph it cannot see (E6104). A parse[T] whose target T is declared in a dependency needs that dependency's field bodies, which are not carried across a module boundary until the intermediate-representation sidecar lands (E6105).
Rationale. Each of these is a real capability with a real design behind it, and each is a diagnostic rather than a silent miscompile. Per I.A(g), a shipped feature is world-class or it is diagnosed, and a limitation met only by colliding with it is a defect.
(g) Rule. A Dictionary becomes a document object, so its key type must have a canonical, lossless, reversible text form: String, the integer types, char8/char32, a plain ENUM (by case name), and boolean qualify. A float, a float-valued number, and any composite key do not, and are diagnostic E6103. The hasher and the comparator are type-level properties and are never serialized — reconstruct re-hashes with the target type's declared hasher, which is statically known because parse[Dictionary[String, Person, DefaultHasher[String]]] names it.
Rationale. Document keys are text; a key whose text form is not reversible cannot survive the round trip, and a float's is not. Re-applying the hasher rather than round-tripping it removes the whole class of "wrong hasher" bug by construction — the data never gets a say in it.
(h) Rule. The one collection whose codec is synthesized in V1 is Array[E] — a document array, its element serialized by the same rules as any other reachable value, so an Array of nested objects reconstructs each element through its own codec. Every other collection-typed reachable field is diagnostic E6107: Set, Queue, Stack, Deque, LinkedList, SortedList, a native T[], an Array of Array, and — for now — Dictionary. The key rule of I.P.g states what a Dictionary key must satisfy when Dictionary synthesis lands; until it does, a Dictionary-typed field is E6107 like the rest.
Rationale. Array is the shape a document array actually has, and it is the one that carries its own growth — the others each need a decision the codec cannot make for itself. Dictionary is the sharpest case: it is an interface over several concrete implementations, so "reconstruct a Dictionary" has no answer until the type carries enough identity for the codec to know which one to build. That is a language question, not a codec question, and a diagnostic is the honest place to leave it (I.A.g — a shipped feature is world-class or it is diagnosed).
(i) Rule. A document has four scalar shapes — string, number, boolean, null — and Envzn has more scalar-ish types than that. A type without a native document shape therefore travels encoded: its wire form is one of the four, and the codec applies a total, reversible encode on serialize and a fallible decode on reconstruct. A ByteBuffer is a base64 string; a decimal128 is its canonical decimal text; a DateTime is an RFC-3339 string; a TimeDuration is an ISO-8601 duration; a char8 is a number, a char32 a one-code-point string. The encode never fails, since every value of the type has a text form. The decode is fallible: a malformed base64 string, a non-canonical decimal, or a bad date is a decode FAILURE, and that failure propagates out of parse exactly as a RESTORE failure does. A value never round-trips to something silently wrong.
Rationale. The document formats Envzn targets are text, and text has only a handful of primitive shapes; the language's richer scalars have to be carried inside those shapes without loss. Making the encode total keeps serialize infallible: a value can always be written. Making the decode fallible keeps reconstruct honest — the document is untrusted input, and a shape that does not parse is a boundary failure rather than a corrupt value. This is the same relocate-the-cost-inward move as the rest of I.P: the developer writes a ByteBuffer field and the compiler carries the base64 both ways.
(j) Rule. A plain ENUM does not travel as a single scalar, because either half of a case's identity can drift independently: a case can be renamed (its name changes) or renumbered/reordered (its ordinal changes, and ordinals are dev-assignable and optional). So an enum serializes as a two-field object carrying both — {"name": <case name>, "value": <ordinal>}. Reconstruct resolves name-first-then-value: the name is looked up against the enum's case table, and only if it names no live case is the ordinal tried; a pair that matches on neither is a decode FAILURE. A document therefore survives a case rename (it recovers by value) or a reorder (it recovers by name). The per-enum case table is compile-time data the compiler emits: the IDENTITY(Enum).cases reflection model (I.K.iii.e). The generic name↔value logic lives once in the kernel EnumReflection namespace, as name, valueForName, and checkValue. The codec emits only the per-enum data and calls that shared logic, never a lookup of its own.
Rationale. A case's name and its ordinal are two independent names for the same thing, and a long-lived document outlives both — a rename here, a renumber there. Persisting only one loses to the other's drift; persisting the pair, and preferring the human-meaningful name, keeps the common edit (reordering cases) lossless while still recovering from a rename. Capturing both is exactly why the enum is an object and not an encoded scalar like (i): neither half alone is a stable key. This mirrors how a complex or a GROUP case travels — a small object whose parts reconstruct together — so the enum's pair reuses the same object-cell machinery those cells do.
(k.i) Rule. The reconstruct façade has two entry points that differ only in how they treat a document that does not exactly match the type. Codec.parse[T] is lenient: a key the type does not declare is skipped, a declared key the document omits is left at its compiler-default (and RESTORE then validates), and only a type mismatch on a present key is a hard FAILURE. Codec.parseStrict[T] is strict — serde's deny_unknown_fields plus required-field checking — so an unknown key or a missing declared key is itself a FAILURE. Strictness applies to the whole reconstructed tree: a nested object, an array element, and a GROUP member are held to the same rule as the root. A DERIVED field never travels, so it is never counted "missing". Both entry points return the clean (T | STATUS) shape — there is no second success slot and no drift-report object.
Rationale. Two documents can disagree with a type in two directions — carrying more than the type expects, or less — and which of those is an error is a policy the caller owns, not the codec. Lenient is the forgiving default that tolerates a producer that added a field or a consumer that dropped one; strict is the contract-enforcing mode for a closed schema. Keeping both on the same (T | STATUS) return keeps the surface one obvious shape, and pushing the flag down the whole tree makes "strict" mean the same thing at every depth rather than only at the root.
Article II — The Envzn Intermediate Representation and its Lowering
Envzn compiles through its own intermediate representation, the EvIR: a fully-analyzed program is built into typed EvIR nodes, and those nodes are lowered to C++20. This article specifies the EvIR in fourteen logical groups, one per Section, stating what each group's nodes are and the essence of how they lower. It is deliberately not exhaustive. The complete node inventory, every traced C++ mapping, and the lowering model live in the companion ENVZN_IR_SPEC.md, generated from the compiler's own spec-trace and held in sync with this constitution.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
(a) The compilation pipeline tokenizes and parses source into an abstract syntax tree, analyzes it, builds the analyzed tree into EvIR, lowers the EvIR to C++20, and invokes clang++. The lowering model the per-node mappings sit inside is recorded in ENVZN_IR_SPEC.md Section C: the pipeline in full, the ownership-handle representation, concurrency, the foreign-function boundary, the generated entry point, diagnostics, and kernel-build notes. The Sections below specify the node groups themselves; each names the spec parts (A.⟨n⟩ for the node set, B.⟨n⟩ for the lowering) that catalogue it in full. Where this article and the generated specification disagree, the specification — anchored to the compiler's own trace — is the authority on lowering, exactly as Article I is the authority on language semantics.
Section II.A — Literals and constants
The leaf value nodes — the constants a program writes directly.
(a) Each literal node carries its resolved type and lowers to the matching C++ literal or kernel value. An integer, float, boolean, or char literal takes its C++ form, a char literal becoming a UTF-32 literal; a string literal becomes an interned immutable String; an array literal becomes an EvArray initialization; and a STATUS literal becomes an _EvStatus. The two absence leaves carry no payload — one stands for an unoccupied class-typed slot, the other for an empty pipe slot. The full node list and every traced lowering are in ENVZN_IR_SPEC.md A.i / B.i.
Section II.B — References and access paths
The nodes that name an existing value rather than compute a new one.
(a) A reference node names a place: a local or parameter read, the receiver SELF, a data-field read through ., or an indexed element coll[i]. Each lowers according to the place's form: an owning handle, a borrow (a raw pointer), or a value-array slot. The same surface syntax therefore compiles to different C++ depending on the binding it resolves to. See ENVZN_IR_SPEC.md A.ii / B.ii.
Section II.C — Operators and conversions
Computation over values, and the explicit conversions between types.
(a) The arithmetic, comparison, bitwise, and shift operators lower to their C++ equivalents. A derived operator — one a type gained by implementing an operator interface (I.K.iv) — lowers to a call of the interface method it was derived from. The AS / INTO conversions lower through the per-type CONVERSIONS host, a C++ namespace of free-function operators, and an opaque wrap/unwrap lowers to the _opq_wrap / _opq_unwrap boxing over void*. Conversions are always explicit and checked; there is no implicit cast. See ENVZN_IR_SPEC.md A.iii / B.iii.
Section II.D — Calls, construction, and lambdas
The nodes that invoke behaviour or build instances.
(a) A call node is overload-resolved before lowering, so it already names its concrete target — a self-call lowers to a bare member call, a static call to Owner::m(...). A CREATE lowers to a heap allocation through _ev_unique<T> followed by the matching INIT constructor, with SUPER(...) emitted in the constructor's initializer list. A lambda lowers to a C++ callable under the non-escaping capture rules of I.F.v. See ENVZN_IR_SPEC.md A.iv / B.iv.
(b) The IDENTITY(subject) intrinsic (I.K.iii(e)) needs no IR node of its own. It lowers to a synthesized construction of the kernel STRUCT Identity: an IrCreate whose arguments are compile-time literals the analyzer has already computed from the subject's static type and binding — String literals, bool literals, and a String[] literal for the ancestor lenses. The construction is emitted like any struct value-constructor call. Because the analyzer has proven the subject legal and the field values are fixed, the emit phase only builds it and never decides anything. (A non-empty String[] literal is an array of move-only owning handles that cannot form a C++ initializer_list, so it lowers through the _ev_object_array_of(...) factory rather than a brace-init; see ENVZN_IR_SPEC.md.)
Section II.E — Bindings and assignment
The nodes that introduce or update storage.
(a) A binding carries its operator, and the operator decides the lowering: = to a value copy or direct initialization, := to a C++ move of the handle, and =@ to taking the address of a place expression. An assignment to an existing local, field, indexed element, or reference lowers to the corresponding C++ store, applying a clone or move where the form requires it. See ENVZN_IR_SPEC.md A.v / B.v.
Section II.F — Control flow
The structured-control nodes.
(a) Blocks, conditionals, and loops lower to their C++ structured-control equivalents. A MATCH lowers by subject: a switch or std::variant visitation for an enum or group, a presence test for a possibly-EMPTY value, an if/else if chain for a condition-form match. FOR IN lowers by iterable: a class collection or user Iterator OF T drives an owned iterator through hasNext() / next(), while a raw array (T[], T[N], T[N+]) is driven by INDEX over the array buffer, binding the loop variable to a borrow pointer into that buffer — a value array's by-value next() cannot hand back the stable pointer the lowered body dereferences. A compile-time WHEN ... IMPLEMENTS block lowers to an if constexpr. See ENVZN_IR_SPEC.md A.vi / B.vi.
Section II.G — Returns and expression statements
How a method hands values back, and how an expression runs for its effect.
(a) A single-value return lowers to a C++ return, applying any required clone or move as the value leaves the callee. A comma-shaped multi-return and a pipe-XOR return both lower to a std::tuple of the slots, the pipe-XOR shape carrying a trailing _EvStatus. An expression statement lowers to its expression evaluated for effect. See ENVZN_IR_SPEC.md A.vii / B.vii.
Section II.H — Recoverable failure and presence
The STATUS / pipe-XOR machinery and the presence tests.
(a) A pipe-XOR call consumed by an IF or WHILE lowers to a test of the result tuple's trailing _EvStatus, with the $=, $=[i], $!, and $# slot reads lowering to std::get accesses of that temporary. The presence tests lower to ordinary checks — IS VALID to a non-empty handle test, and IS SUCCESS / IS FAILURE / IS PARTIAL_SUCCESS to an _EvStatus outcome test. See ENVZN_IR_SPEC.md A.viii / B.viii.
Section II.I — Unrecoverable failure and assertions
The PANIC / RECOVER path and the assertion family, distinct from recoverable failure.
(a) A PANIC lowers to a C++ throw of an Error subclass and a TRY to a try whose RECOVER arms become catch blocks and whose FINALLY runs on every exit path. An ASSERT lowers to a guarded halt — an abort() on failure — which a production build may elide. See ENVZN_IR_SPEC.md A.ix / B.ix.
Section II.J — Concurrency
The structured-concurrency nodes.
(a) A CONCURRENT scope lowers to an _ev_task_scope RAII object that joins before it unwinds. A PARALLEL block lowers to a task launched on a std::thread registered with that scope, and a SYNCHRONIZED(lock) block to a std::lock_guard released on every exit path. The channel, broker, mutex, future, atomic, and worker-pool primitives lower onto the kernel's concurrency runtime. See ENVZN_IR_SPEC.md A.x / B.x and the concurrency model of I.L.
Section II.K — Program structure
The declaration nodes that give a module its shape.
(a) A class lowers to a C++ class: fields to members, methods to member functions, the MODIFY/read-only distinction to the C++ const qualifier, INIT and CLEANUP to constructor and destructor, and FINAL to the final specifier. An interface lowers to an abstract class of pure-virtual signatures, a struct to a public-field record, a group to a std::variant, and an enum to a C++ enum class. See ENVZN_IR_SPEC.md A.xi / B.xi.
Section II.L — Foreign and unsafe
The typed foreign-function boundary and the UNSAFE gate.
(a) A FOREIGN BIND lowers to a direct call of the bare C symbol with no wrapper, the SETS_ERRNO and owned-return markers adding a thin translation. A FOREIGN BIND CONSTANT lowers to a link-time macro-shim symbol, and a FOREIGN_TYPE BIND to a void* newtype for the opaque form or a ⟨cpp-type⟩ * for the concrete form. An inline FOREIGN { } block never reaches lowering — it is rejected in the analyzer as E3041 (I.N(h.ii)); an UNSAFE { } block lowers to a plain C++ scope gating raw-handle access. See ENVZN_IR_SPEC.md A.xii / B.xii and the boundary tables of C.vi.
Section II.M — Debug instrumentation
The debug-only capture nodes.
(a) SNAPSHOT and STACKTRACE lower to debug-build capture calls and are elided otherwise; neither changes the meaning of the program it observes. See ENVZN_IR_SPEC.md A.xiii / B.xiii.
Section II.N — Types and lifetime
The type representation every node carries, and the scope-exit destruction marker.
(a) ResolvedType is the resolved type the analyzer gives each node: a primitive, class handle, value-array, or collection. It drives every lowering decision, from the C++ type a binding emits to the owning-handle shape (_ev_unique<T> versus _ev_shared<T>) a class field takes. The scope-exit drop marker records where an owned value's life ends; the C++ destructor emits the actual release. See ENVZN_IR_SPEC.md A.xiv / B.xiv and the ownership-handle model of C.ii.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Article III — Grammar (EBNF)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
This article gives the concrete grammar of Envzn in EBNF. It is a consolidation of the rules stated in prose across Article I, not a new authority. Where a production here and the normative prose of Article I disagree, Article I wins, and this article is the bug to fix. The productions are grounded section by section, and each group cites the section it formalizes: lexical forms in I.C, types in I.D (array forms in I.J), declarations in I.E, expressions in I.F, control flow in I.H.
Coverage. The grammar is now complete across all three tranches. Tranche 1: the lexical grammar, module/declaration skeletons, binding/assignment forms, and control flow (with the I.H.i condition sub-grammar). Tranche 2: the type grammar of I.D (III.3) and the expression grammar of I.F (III.5). Tranche 3: the MATCH pattern language and the WHEN / TRY / PANIC statement forms (III.4), and the class, interface, struct, enum, group, namespace, method, and conversion-host declarations of I.K / I.M.ii / I.D.viii (III.6). Two productions remain deliberately informal and are flagged inline where they occur: the platform arm of conditional compilation (I.H.vi), and the exact argument grammar of the assertion family (ENVZN_USER_GUIDE.md III.C). Everything else is fully derivable.
III.0 — Notation
The metasyntax is EBNF with the following conventions.
rule = definition ; (* a production; terminated by ; *)
a | b (* alternation: a or b *)
[ a ] (* optional: zero or one a *)
{ a } (* repetition: zero or more a *)
( a b ) (* grouping *)
"IF" (* a terminal — a literal keyword or symbol *)
'a' (* a terminal character *)
? description ? (* a terminal defined informally in prose *)
(* ... *) (* a comment, not part of the grammar *)
lower_case (* a non-terminal *)
Uppercase quoted terminals ("IF", "THEN") are reserved keywords; the full keyword set is the Reserved Identifiers Catalog above. Two lexical terminals recur and are named once here: NEWLINE is one or more line terminators, and INDENT/whitespace between tokens is insignificant except that NEWLINE terminates a statement (III.1).
III.1 — Lexical grammar (formalizes I.C)
(* — comments (I.C.b): removed before parsing, may appear wherever whitespace may *)
line_comment = "//" , { ? any character except a line terminator ? } ;
block_comment = "/*" , { ? any character, including line terminators ? } , "*/" ;
doc_comment = "///" , [ " " ] , { ? any character except a line terminator ? } ;
(* — statement termination (I.C.c): the newline IS the terminator; no semicolon,
save the single C-style FOR header of III.4 *)
statement_end = NEWLINE ; (* a NEWLINE terminates only at bracket depth 0 — inside an
unclosed "(" or "[" it is insignificant whitespace; block
braces never suppress (I.C.c; AMB-007, pinned 2026-07-21) *)
(* — identifiers (I.C.d) *)
letter = 'A'..'Z' | 'a'..'z' ;
digit = '0'..'9' ;
identifier = letter , { letter | digit | "_" } ; (* no leading "_"; not a keyword *)
(* — numeric literals (I.C.e): three bases, underscores between digits only *)
digit_sep = "_" ;
dec_digits = digit , { [ digit_sep ] , digit } ;
hex_digit = digit | 'a'..'f' | 'A'..'F' ;
hex_digits = hex_digit , { [ digit_sep ] , hex_digit } ;
bin_digits = ( "0" | "1" ) , { [ digit_sep ] , ( "0" | "1" ) } ;
int_literal = dec_digits | ( "0x" , hex_digits ) | ( "0b" , bin_digits ) ;
float_literal = dec_digits , "." , dec_digits , [ exponent ]
| dec_digits , exponent ;
exponent = ( "e" | "E" ) , [ "+" | "-" ] , dec_digits ;
(* — imaginary term (I.C.f): a coefficient juxtaposed with the constant "im" *)
imaginary = ( int_literal | float_literal | identifier ) , "im" ;
numeric_literal = int_literal | float_literal | imaginary ;
(* — character and string literals (I.C.g) *)
escape = "\\n" | "\\t" | "\\r" | "\\\\" | "\\'" | "\\\"" | "\\0"
| "\\v" | "\\f"
| "\\x" , hex_digit , hex_digit
| "\\u" , 4 * hex_digit
| "\\U" , 8 * hex_digit ;
char_literal = "'" , ( ? one Unicode scalar ? | escape ) , "'" ;
string_literal = '"' , { ? any char except '"' or a line terminator ? | escape } , '"' ;
multiline_str = '/"' , { ? any character, including line terminators ? } , '"/' ;
III.2 — Source file and module structure (formalizes I.A.i, I.N)
A module is a directory (I.N); the grammar is per file. One class per .ev file; supporting types are grouped into interfaces.ev, enums.ev, structs.ev (I.A.i.b).
source_file = { NEWLINE } , { alias_decl , { NEWLINE } } ,
{ top_level_decl , { NEWLINE } } ;
alias_decl = "ALIAS" , identifier , ( "::" , identifier | "." , identifier ) ;
(* file-scoped dependency import — ALIAS Mod::Type / ALIAS Ns.func
(Appendix; added 2026-07-22, GOLDEN probe AMB-029). The FOREIGN /
BIND FFI declaration surface remains outside this grammar for now. *)
top_level_decl = class_decl
| interface_decl
| struct_decl
| enum_decl
| group_decl
| namespace_decl
| conversions_decl ; (* CONVERSIONS host, I.D.viii *)
III.3 — Declarations, bindings, and types (formalizes I.E and I.D)
(* ── the type grammar (formalizes I.D; array forms from I.J) ────────── *)
type = core_type , [ array_suffix ] ; (* at most ONE suffix — chained T[N][M] is retired, E2124 *)
core_type = primitive_type
| generic_type
| qualified_name ; (* a CLASS / INTERFACE / STRUCT / ENUM / GROUP type, including
the built-in value types String, ByteBuffer, DynamicString,
DynamicByteBuffer, StringView, ByteBufferView *)
qualified_name = [ identifier , "::" ] , identifier ; (* module-qualified name, I.N *)
primitive_type = signed_int | unsigned_int | float_type
| "boolean" | "binary"
| "char" | "char8" | "char16" | "char32"
| "opaque" | "number" | "complex" | "decimal128" ; (* opaque is BARE in type position;
opaque[T] is call-site only, I.D.v *)
signed_int = "int8" | "int16" | "int32" | "int" | "int64" | "int128" | "i128" ;
unsigned_int = "uint8" | "uint16" | "uint32" | "uint" | "uint64" | "uint128" | "u128" ;
float_type = "float16" | "float32" | "float" | "float64" | "double" ;
(* generic / parametric types — the I.J collections: Array[T], Set[V, H], Queue[T], Box[T], … *)
generic_type = "Dictionary" , "[" , dict_args , "]" (* the map type — two spellings, see NOTE *)
| identifier , "[" , type_arg_list , "]" (* Array[T], Set[V,H], LinkedList[T], … *)
| identifier , "OF" , type ; (* the OF constraint form: Iterator OF T, Hasher OF K (I.J) *)
dict_args = type , ":" , type (* colon sugar: Dictionary[K : V] — I.D.v *)
| type , "," , type , [ "," , type ] ; (* full form: Dictionary[K, V] / [K, V, H] — I.J *)
type_arg_list = type , { "," , type } ;
(* NOTE: Article I shows the map type under TWO spellings — the colon sugar Dictionary[K : V] (I.D.v)
and the comma list Dictionary[K, V, H] (I.J). Both are admitted here and FLAGGED for reconciliation. *)
(* array + dense N-D forms (I.J.i(a–t)) *)
array_suffix = "[" , "]" (* T[] unbounded value- or object-array (I.J.i(a–c)) *)
| "[" , extent , "]" (* T[N] fixed-capacity, no growth (I.J.i(b)) *)
| "[" , extent , "+" , "]" (* T[N+] pre-sized growable (I.J.i(l)) *)
| "[" , nd_spine , "]" (* T[,] T[,,] N-D rank spine (I.J.i(o)) *)
| "[" , extent , { "," , extent } , "]" ; (* T[m,n] runtime / T[3,4] fixed N-D extents (I.J.i(o)) *)
(* AMBIGUITY — `generic_type` vs the N-D extent form of `array_suffix`. Since the N-D element type
may be an OBJECT (I.J.i(p)), `Foo[A,B]` is TOKEN-IDENTICAL under both productions: a parametric
instantiation (type-args A,B) and a rank-2 N-D array of Foo with runtime extents A,B. The grammar
alone cannot separate them; the rule is:
- Any slot that a type-argument list could never hold decides it as N-D IMMEDIATELY:
an EMPTY slot (`Foo[,]`), an integer literal (`Foo[3,4]`), or any other expression (`Foo[n+1,m]`).
- An ALL-IDENTIFIER slot list (`Foo[A,B]`) is decided AFTER name resolution, on the HEAD:
if Foo takes type parameters it is a parametric instantiation; if Foo is a non-generic type
(String, a CLASS, …) it can only be an N-D array, and the identifiers are extents.
This mirrors the single-bracket `T[X]` rule, where X is resolved to a class-scope CONSTANT before
the fixed-array reading is chosen. Extents are VALUES; type-args are TYPES. *)
extent = int_literal | identifier ; (* a literal, a CONSTANT, or a run-time extent expr *)
nd_spine = "," , { "," } ; (* all-empty; one comma per extra axis, rank ≤ 15 *)
(* — a binding: at most ONE new typed variable per assignment line (I.E.c) *)
local_decl = [ handle_qual ] , type , identifier , [ binding_init ] ;
handle_qual = "REFERENCE" | "MUTABLE" , "REFERENCE" ; (* non-owning / mutable-non-owning local binding (I.I) *)
binding_init = assign_op , expression ;
assign_op = "=" | ":=" | "=@" ; (* bind value | own (construct/clone/move) | reference (I.G) *)
(* — a field carries modifiers (I.E); REFERENCE decides ownership (I.D.iv(f), I.I) *)
field_decl = { field_modifier } , type , identifier , [ binding_init ] ;
field_modifier = "REFERENCE" | "MUTABLE" | "PRIVATE" | "PROTECTED"
| "CONSTANT" | "SHARED" | "VOLATILE" | "FINAL" ;
(* — a comma-shape multi-value receive declares the extra names on prior lines (I.E.c) *)
multi_receive = type , multi_slot , { "," , multi_slot } , ":=" , expression ;
multi_slot = identifier | "$?" ; (* $? discards that slot (I.M.i; AMB-021) *)
III.4 — Control flow (formalizes I.H — fully expanded)
statement = local_decl
| assignment
| if_stmt
| while_stmt | until_stmt | do_loop
| for_in_stmt | for_range_stmt | for_c_stmt | hotloop_stmt
| repeat_stmt | loop_stmt
| match_stmt
| when_typecheck_stmt | conditional_compile_stmt
| try_stmt | assert_stmt
| postfix_stmt
| jump_stmt
| anon_block | unsafe_stmt | synchronized_stmt | parallel_stmt
| expr_stmt ;
block = "{" , { NEWLINE } ,
[ statement , { statement_end , { NEWLINE } , statement } ,
[ statement_end ] , { NEWLINE } ] , "}" ;
(* "}" may close the final statement without a newline, so the
one-line form IF (c) THEN { one-statement } is legal — but a
one-line block admits EXACTLY ONE statement: two statements
with only a space between them are underivable here and are
the I.C.c two-statements-one-line compile error.
(Ruled 2026-07-22, GOLDEN probe AMB-008.)
Blocks nest to a maximum depth of fifteen (I.A.i(d)); the
grammar itself is unbounded, so the limit is a static check
the analyzer applies rather than a derivable property. *)
jump_stmt = ( "BREAK" , [ identifier ] ) | ( "CONTINUE" , [ identifier ] )
| return_stmt | panic_stmt ; (* the identifier names a LABEL — I.H.iii(e) *)
anon_block = block ; (* anonymous scoping block — bounds a lifetime (I.G.e; AMB-030) *)
unsafe_stmt = "UNSAFE" , block ; (* I.I.viii *)
synchronized_stmt = "SYNCHRONIZED" , block ; (* I.L *)
parallel_stmt = "PARALLEL" , block ; (* I.L; all four ruled valid 2026-07-22, GOLDEN probe AMB-030 *)
(* ── I.K.iii — WHEN type-check (narrowing; mandatory ELSE, optional DO) ── *)
when_typecheck_stmt
= "WHEN" , expression , type_predicate , [ "DO" ] , block , "ELSE" , block ;
type_predicate = "IS" , [ "NOT" ] , ( type | "PRIMITIVE" ) | "IMPLEMENTS" , type ;
(* ── I.H.vi — conditional compilation (build-time; mandatory ELSE) ── *)
conditional_compile_stmt
= "WHEN" , ( "Build" , "IS" , ( "DEBUG" | "RELEASE" )
| "FEATURE" , string_literal
| platform_test ) , block , "ELSE" , block ;
platform_test = ? a test against the System platform vocabulary (I.H.vi) — FLAGGED, not pinned here ? ;
(* ── I.M.ii — PANIC / TRY / RECOVER / FINALLY ── *)
panic_stmt = "PANIC" , type , "(" , [ arg_list ] , ")" ; (* PANIC ErrorType(args); site fields injected *)
try_stmt = "TRY" , block ,
{ { NEWLINE } , "RECOVER" , "(" , type , identifier , ")" , block } , (* parens required; no `as`; subclass before super *)
[ { NEWLINE } , "FINALLY" , block ] ; (* FINALLY optional; a TRY clause — the only place FINALLY is
valid. RECOVER/FINALLY follow the AMB-009 layout rule of
if_stmt: next line after a multi-line block. *)
(* ── ENVZN_USER_GUIDE.md III.C — the assertion family (exact argument grammar lives in ENVZN_USER_GUIDE.md III.C) ── *)
assert_stmt = ( "ASSERT" | "ASSERT!" ) , "(" , bool_expr , [ "," , expression ] , ")"
| "UNREACHABLE!" , [ "(" , [ expression ] , ")" ] ; (* FLAGGED: assertion arg grammar not pinned in Article I *)
(* ── I.H.i — conditional headers ─────────────────────────────────────
A condition is simple or compound. A simple condition needs EXACTLY ONE
of { surrounding parentheses, trailing marker }; a bare simple condition
with neither is a compile error and is un-derivable below by construction.
A compound condition parenthesizes each clause; its outer parens and its
marker are both optional. The marker is THEN for IF, DO for WHILE/UNTIL. *)
cond_then = simple_cond_then | compound_cond_then ;
simple_cond_then
= "(" , bool_expr , ")" , [ "THEN" ] (* parenthesized: marker optional *)
| bool_expr , "THEN" ; (* marker form: parentheses omitted *)
compound_cond_then
= [ "(" ] , paren_clause ,
{ bool_op , paren_clause } , [ ")" ] , [ "THEN" ] ;
paren_clause = "(" , bool_expr , ")" ;
bool_op = "AND" | "OR" | "XOR" ;
cond_do = ? cond_then with the marker terminal "DO" substituted for "THEN" ? ;
(* ── I.H.ii — the conditional ─────────────────────────────────────── *)
if_stmt = "IF" , cond_then , block ,
{ { NEWLINE } , "ELSE" , "IF" , cond_then , block } ,
[ { NEWLINE } , "ELSE" , block ] ;
(* Continuation-keyword layout (AMB-009, ruled 2026-07-22): after a
MULTI-line block the ELSE / ELSE IF must start the next line —
cuddled `} ELSE {` there is the I.C.c layout error; only after a
ONE-line block may the continuation share the line. The layout
constraint is enforced as a layout rule, not encoded in EBNF. *)
(* ── I.H.iii — loops ──────────────────────────────────────────────── *)
while_stmt = "WHILE" , cond_do , block ;
until_stmt = "UNTIL" , cond_do , block ; (* readable WHILE NOT *)
do_loop = "DO" , block , { NEWLINE } , ( "WHILE" | "UNTIL" ) , condition_tail ;
(* the tail is MANDATORY, so a next-line WHILE/UNTIL is unambiguous —
it is always this loop's tail, never a new statement (AMB-009). *)
condition_tail = "(" , bool_expr , ")" | bool_expr ; (* post-loop: no marker (I.H.i.a) *)
for_in_stmt = "FOR" , identifier , "IN" , expression , block ; (* FOREACH withdrawn 2026-08-18; reserved *)
for_range_stmt = "FOR" , identifier , "=" , expression ,
( "TO" | "UNTIL" ) , expression ,
[ "STEP" , expression ] , block ; (* TO inclusive, UNTIL exclusive *)
for_c_stmt = "FOR" , type , identifier , "=" , expression , ";" ,
bool_expr , ";" , simple_stmt , block ; (* the ONLY semicolons; one line *)
hotloop_stmt = "HOTLOOP" , ( for_range_stmt | for_c_stmt | while_stmt ) ;
(* I.H.iii(f) — bounds-check-HOISTING modifier; must immediately
precede a counted FOR or a WHILE. The analyzer proves the
hoist or rejects (E5027); it never skips a check. *)
repeat_stmt = "REPEAT" , [ expression ] , block ;
(* with a count: runs the body that many times. WITHOUT one:
the bare infinite loop, exited by BREAK — I.H.iii(c). *)
(* ── I.H.iii(e) — LABEL, the block/loop naming prefix ──────────────── *)
label_stmt = "LABEL" , identifier , ( loop_stmt | for_in_stmt | for_range_stmt
| for_c_stmt | while_stmt | until_stmt
| do_loop | repeat_stmt | block ) ;
(* the name is unique within the CLASS; its extent is the block
it prefixes. BREAK <name> / CONTINUE <name> target it. *)
(* ── I.H.iii(d) — LOOP, exactly ONE form ──────────────────────────── *)
loop_stmt = "LOOP" , identifier , "IN" , expression ,
"WITH" , "INDEX" , identifier , block ;
(* element binds as REFERENCE (as FOR IN); index is a read-only
zero-based int32. BOTH clauses mandatory — see I.H.iii(d).
The iterator-handle and `LOOP $_ IN` forms are WITHDRAWN. *)
(* ── I.H.v — MATCH (statement; no fallthrough) ────────────────────── *)
match_stmt = "MATCH" , expression , "{" , { NEWLINE } ,
{ when_arm , { NEWLINE } } , [ default_arm , { NEWLINE } ] , "}" ;
when_arm = "WHEN" , when_pattern , ":" , block ;
default_arm = "DEFAULT" , ":" , block ; (* ONLY the compile-time IS/IMPLEMENTS form (I.K.iii.c) *)
when_pattern = "IS" , [ "NOT" ] , ( type | "PRIMITIVE" ) (* type-check arm (I.K.iii.c) *)
| "IMPLEMENTS" , type
| status_pattern (* STATUS variant (I.M.i) *)
| enum_case_pattern (* enum case — bare name, no payload (I.K.viii) *)
| type , identifier ; (* type binding (I.H.v) *)
(* Removed 2026-08-02 per I.H.v(a.i): EMPTY arm, DEFAULT arm, IF guards,
compare_op / TO-range / literal-list patterns, and the subjectless
(bool_expr) condition role. The subject is now mandatory. *)
status_pattern = "SUCCESS"
| "FAILURE" , "(" , identifier , [ "," , identifier ] , ")" (* binds message[, code] *)
| "PARTIAL_SUCCESS" , "(" , identifier , ")" ;
enum_case_pattern
= [ identifier , "." ] , identifier ; (* NORTH | Direction.NORTH *)
(* A V1 enum case is a BARE NAME. There is no syntax to declare an associated
value on an ENUM case (I.K.viii), so no arm can bind one; STATUS is the only
closed set whose alternatives carry a payload, and status_pattern above is
its dedicated production. *)
(* NOTE: several arms share a surface shape — an identifier may begin a type binding, an
enum case, or an expression — and which arm applies is resolved by the MATCH subject's
type, not by syntax alone. `labeled_slot` is defined with the return shapes in III.6. *)
(* ── I.H.iii(e) — postfix single-statement forms ──────────────────── *)
postfix_stmt = simple_stmt , "FOR" , identifier , "IN" , expression
| simple_stmt , "WHILE" , condition_tail ;
simple_stmt = assignment | expr_stmt ;
III.5 — Expressions (formalizes I.F — fully expanded)
Envzn fixes an explicit precedence order only for the arithmetic symbols (I.F.ii). The keyword operators — bitwise, shift, boolean — are written as words precisely to keep them out of a precedence tower, and I.H.i requires each clause of a compound boolean condition to be parenthesized. The tiers below encode the orders I.F.ii states and lean on explicit grouping everywhere I.F.ii declines to fix one; any tier whose relative precedence Article I does not pin down is flagged inline.
(* deepest = highest-binding; `expression` is the loosest form *)
expression = logical_expr ;
logical_expr = coalesce_expr , { ( "AND" | "OR" | "XOR" ) , coalesce_expr } ;
(* NOTE: in a condition header, I.H.i additionally requires each
boolean clause to be parenthesized — see the cond grammar in III.4 *)
coalesce_expr = convert_expr , { "??" , convert_expr } ; (* ?? coalescing (I.D.iv) *)
convert_expr = compare_expr , { ( "AS" | "INTO" ) , type } ; (* x AS int32, userInput INTO int32 (I.D.viii) *)
compare_expr = bitwise_expr , [ compare_op , bitwise_expr ] (* non-associative: at most one comparison *)
| bitwise_expr , presence_test ;
compare_op = "==" | "!=" | ">" | "<" | ">=" | "<="
| "≈" | "≉" (* number-only; same precedence as == (I.F.ii(c)) *)
| "BEQUALS" | "BNEQUALS" ; (* bit-pattern equality (I.F.ii(c)) *)
presence_test = "IS" , [ "NOT" ] , "VALID" (* presence test (I.D.iv/e); the SUBJECT must be a
type with an EMPTY state — not a String/ByteBuffer
(always present, E6068), a REFERENCE (E6041), a
primitive (E6042), a STATUS (E6043), an enum
(E6044) or a STRUCT (E6045) — see I.I.iii *)
| "IS" , ( "SUCCESS" | "FAILURE" | "PARTIAL_SUCCESS" ) ; (* STATUS test (I.M) *)
(* NOTE: Article I does not fix the relative precedence of the keyword bitwise and shift operators and
steers authors to parenthesize (I.F.ii); the two tiers below are a conservative reading — arithmetic
binds tighter than shifts, shifts tighter than the BAND/BOR/BXOR family — and are FLAGGED. *)
bitwise_expr = shift_expr , { ( "BAND" | "BOR" | "BXOR" ) , shift_expr } ;
shift_expr = additive_expr ,
{ ( "LSHIFT" | "RSHIFT" | "ASHIFT" | "LROTATE" | "RROTATE" ) , additive_expr } ;
additive_expr = mult_expr , { ( "+" | "-" ) , mult_expr } ;
mult_expr = unary_expr , { ( "*" | "/" | "~/" | "%" | "#" ) , unary_expr } ; (* # = hash-combine (I.F.ii(a)) *)
unary_expr = ( "NOT" | "BNOT" | "-" | "++" | "--" ) , unary_expr (* prefix (I.F.ii(a,b)) *)
| power_expr ;
power_expr = postfix_expr , [ "^" , unary_expr ] ;
(* ^ left operand is postfix-only, so a prefix minus is never captured
under it: -2 ^ 2 derives as -(2 ^ 2), per I.F.ii(a). The right
operand admits unary (2 ^ -1 parses) and re-enters unary→power,
keeping ^ right-associative. (Corrected 2026-07-21 — the earlier
power_expr = unary_expr ["^" power_expr] derived (-2)^2, contradicting
I.F.ii(a); found by the GOLDEN clean-room probe, AMB-012.) *)
postfix_expr = primary , { postfix_op } ;
postfix_op = "." , identifier (* field / NAMESPACE member (I.F.i) *)
| "." , identifier , [ "[" , type_arg_list , "]" ] ,
"(" , [ arg_list ] , ")" (* NAMESPACE method call, optional call-site type args (I.K.vi.f.i) *)
| "->" , identifier , [ "[" , type_arg_list , "]" ] ,
"(" , [ arg_list ] , ")" (* method call, optional call-site type args (I.F.iv, I.K.vi) *)
| "[" , expression , { "," , expression } , "]" (* index; multi-coordinate for an N-D array (I.J) *)
| "!" (* factorial — binds tighter than ^ (I.F.ii(a)) *)
| "++" | "--" ; (* postfix increment / decrement (I.F.ii(b)) *)
primary = literal
| "SELF" | "SUPER"
| "." , identifier (* implicit-SELF field read, .field (I.G.d; AMB-031, 2026-07-22) *)
| dollar_token
| construct_expr
| identity_expr
| opaque_static
| format_call
| lambda_expr
| identifier
| "(" , expression , ")" ;
literal = numeric_literal | char_literal | string_literal | multiline_str
| "TRUE" | "FALSE" | "EMPTY" | array_literal ;
array_literal = "[" , [ expression , { "," , expression } ] , "]" ; (* e.g. [2, 3, 5, 7, 11] (I.J) *)
construct_expr = "CREATE" , [ qualified_name ] , [ "[" , type_arg_list , "]" ] ,
"(" , [ arg_list ] , ")" ; (* class name inferred when concrete, required for interface/abstract (I.F.iii) *)
identity_expr = "IDENTITY" , "(" , identity_subject , ")" ; (* compile-time reflection intrinsic (I.K.iii(e)) *)
identity_subject = qualified_name , [ "[" , type_arg_list , "]" ] (* a type / type-parameter, optionally parameterized *)
| identifier , { "." , identifier } ; (* a value variable or a data-field path *)
opaque_static = "opaque" , "[" , type , "]" , "->" , identifier ,
"(" , [ arg_list ] , ")" ; (* opaque[T]->wrap/unwrap — the two call-site statics (I.D.v) *)
format_call = ( string_literal | multiline_str ) , "->" , "format" ,
"(" , [ arg_list ] , ")" ; (* positional composition over $1..$9 (I.F.vi) *)
arg_list = arg , { "," , arg } ;
arg = expression ; (* $? is NOT an argument — E1136; its legal homes are the
discard-bind and multi-receive slots of III.3/III.5 (I.M.i).
Corrected 2026-07-21, GOLDEN probe AMB-021. *)
dollar_token = "$=" , [ "[" , int_literal , "]" ] | "$RETURNED" (* success-slot reads (I.M.i) *)
| "$!" | "$ERR_MESSAGE" | "$#" | "$ERR_CODE" ; (* failure-side reads (I.M.i) *)
(* ── lambdas (I.F.v) — four forms; the body decides which ──────────── *)
lambda_expr = "LAMBDA" , lambda_params , ":" , expression (* expression form: LAMBDA x: x + 1 *)
| "LAMBDA" , "(" , [ name_list ] , ")" , block (* block form: parenthesized params + brace body *)
| "LAMBDA" , ":" , block (* no-param colon-block *)
| "LAMBDA" , "(" , ")" ; (* no-param empty form *)
lambda_params = name_list | "(" , name_list , ")" ; (* expression-form params, optionally parenthesized *)
name_list = identifier , { "," , identifier } ;
(* ── lvalues, assignment, returns (the entry points III.3 and III.4 depend on) ── *)
assignment = place , assign_op , expression
| "$?" , ":=" , expression (* explicit whole-return discard (I.M.i) *)
| multi_receive ;
place = ( identifier | "SELF" | "." , identifier )
, { place_suffix } ; (* an assignable location — never a method call;
the leading-dot head is implicit-SELF field
access, .field (I.G.d; AMB-031, 2026-07-22) *)
place_suffix = "." , identifier
| "[" , expression , { "," , expression } , "]" ;
bool_expr = expression ; (* an expression of boolean type; a compound boolean *condition* parenthesizes per I.H.i *)
return_stmt = "RETURN" , [ "(" , expression , { "," , expression } , ")" ] ;
expr_stmt = expression ; (* e.g. a method call evaluated for effect *)
III.6 — Type, member, and conversion declarations (formalizes I.K, I.M.ii, I.D.viii)
These expand the top_level_decl alternatives named in III.2. A user-defined error type (I.M.ii) is an ordinary class_decl that EXTENDS Error — it needs no production of its own.
(* ── templated types (I.K.ix) — the qualifier sits on the line above the class header ── *)
template_decl = "TEMPLATE" , ":" , "GIVEN" , type_params_spec , ":" ;
type_params_spec = "TYPE" , identifier , "IS" , constraint
| "TYPES" , name_list , ";" ,
param_constraint , { ";" , param_constraint } ;
param_constraint = identifier , "IS" , constraint ;
constraint = alternative , { "," , alternative } ; (* comma reads as OR *)
alternative = atom | "(" , atom , { "AND" , atom } , ")" ; (* the parenthesized form is the only AND *)
atom = ( type | "PRIMITIVE" ) , [ "(" , "EXCEPT" , name_list , ")" ] ; (* EXCEPT only after GROUP / PRIMITIVE *)
(* ── classes (I.K.i–iii, v–vii) ── *)
class_decl = [ template_decl ] , { class_mod } , class_kind , identifier ,
[ class_type_params ] ,
[ "EXTENDS" , type ] ,
[ "IMPLEMENTS" , type , { "," , type } ] ,
"{" , { class_member } , "}" ;
class_kind = [ "HIDDEN" ] ,
( "CLASS" | "ABSTRACT" , "CLASS" | "SINGLETON" , "CLASS"
| "SHARED" , "CLASS" | "VALUE" , "CLASS" ) ;
(* VALUE = the value-semantics kind of I.J; HIDDEN = the
implementation-hiding prefix of I.K (E1130/E1131); which
combinations are legal is semantic, not grammatical.
(Added 2026-07-22, GOLDEN probe AMB-028.) *)
class_mod = "FINAL" | deprecated ;
class_type_params = "[" , ( name_list | identifier , ":" , identifier ) , "]" ; (* [T] / [K, V, H] / map [K: V] *)
deprecated = "DEPRECATED" , [ "(" , string_literal , ")" ] ;
class_member = field_decl (* III.3 *)
| method_decl
| init_decl
| cleanup_decl
| operator_decl
| nested_type ;
nested_type = "PRIVATE" , ( enum_decl | struct_decl ) ; (* only a PRIVATE enum / struct may nest (I.K.i) *)
(* ── methods, INIT, CLEANUP (I.K.v–vi) ── *)
method_decl = [ template_decl ] , (* mandatory when type params are declared — I.K.vi.f *)
{ method_mod } , "METHOD" , identifier , [ "[" , name_list , "]" ] ,
"(" , [ param_list ] , ")" , [ return_clause ] , block ;
method_mod = "PRIVATE" | "PROTECTED" | "INTERNAL" (* absent = public — I.K.iii.b *)
| "MODIFY" | "OVERRIDE" | "FINAL" | "AUTO" | deprecated ;
init_decl = "INIT" , "(" , [ param_list ] , ")" , block ; (* SUPER(args), if present, is the first statement *)
cleanup_decl = "CLEANUP" , block ;
param_list = param , { "," , param } ;
param = [ param_mod ] , param_type , [ "..." ] , identifier , [ "=" , expression ] ;
param_mod = "REFERENCE" | "MUTABLE" , "REFERENCE" | "MOVE" ; (* LOAD is a FOREIGN-BIND-only modifier (I.N(h.i)) *)
param_type = type | "LAMBDA" ; (* a lambda-typed parameter (I.F.v) *)
(* ── return shapes (I.K.vi, I.M.i): comma fills every slot; a single trailing `|` is pipe-XOR ── *)
return_clause = "RETURNS" , ( "VOID" | type | return_tuple ) ;
return_tuple = "(" , labeled_slot , { "," , labeled_slot } ,
[ "|" , labeled_slot ] , ")" ; (* the `|` appears at most once and last (I.M.i) *)
labeled_slot = type , identifier ;
(* ── interfaces (I.K.ii, ix.f) ── *)
interface_decl = "INTERFACE" , identifier , [ iface_type_params ] ,
[ "EXTENDS" , iface_ref , { "," , iface_ref } ] ,
"{" , method_sig , { method_sig } , "}" ; (* >= 1 method; an empty interface is an error *)
iface_type_params = "OF" , identifier | "[" , name_list , "]" ; (* single param uses OF; multi uses brackets *)
iface_ref = identifier , [ "OF" , type | "[" , type_arg_list , "]" ] ;
method_sig = "METHOD" , identifier , [ "[" , name_list , "]" ] ,
"(" , [ param_list ] , ")" , [ return_clause ] ; (* signature only, no body *)
(* ── structs, enums, groups, namespaces (I.K.viii, ix.g; I.N) ── *)
struct_decl = "STRUCT" , identifier , "{" , { field_decl } , "}" ;
enum_decl = "ENUM" , identifier , "{" , enum_case , { sep , enum_case } , "}" ;
enum_case = identifier , [ "=" , expression ] (* simple / raw-value *)
| identifier , "(" , labeled_slot , { "," , labeled_slot } , ")" ; (* associated-value *)
group_decl = "GROUP" , identifier , "{" , [ group_member , { sep , group_member } ] , "}" ;
group_member = qualified_name ; (* primitive / class / struct / interface / GROUP;
a parametric class appears as its BARE HEAD,
no type arguments (I.J.v.a) *)
namespace_decl = "NAMESPACE" , identifier , "{" ,
{ method_decl | constant_field | foreign_constant_decl } , "}" ; (* free functions, CONSTANT data, bound C constants (I.K.ii.d) *)
constant_field = { "PRIVATE" | "INTERNAL" } , "CONSTANT" , type , identifier , [ "=" , expression ] ;
(* absent = public — I.E.f *)
foreign_constant_decl = "FOREIGN" , "BIND" , "CONSTANT" , type , identifier ; (* §17; also legal inside namespace_decl *)
sep = "," | NEWLINE ; (* enum / group members: comma or newline *)
(* ── conversion hosts (I.D.viii) ── *)
conversions_decl = "CONVERSIONS" , identifier , "{" , { operator_decl | method_decl | constant_field } , "}" ; (* operators, PRIVATE helpers, CONSTANT data — I.D.viii.d *)
operator_decl = "OPERATOR" , ( "AS" | "INTO" ) ,
"(" , "FROM" , type , identifier , ")" ,
"RETURNS" , ( type | return_tuple ) , block ; (* RETURNS mandatory; also legal as a class member *)
III.7 — Coverage ledger
| Group | Section | Status |
|---|---|---|
| Lexical grammar | I.C | specified (III.1) |
| Source file / module skeleton | I.A.i, I.N | specified (III.2) |
| Declarations & bindings | I.E | specified (III.3) |
| Control flow (all forms) | I.H | specified (III.4) |
| Condition sub-grammar | I.H.i | specified (III.4) |
| Type grammar (primitives, generics, arrays, N-D) | I.D, I.J | specified (III.3) |
Expression grammar (operators, calls, lambdas, $-tokens) |
I.F | specified (III.5) |
MATCH pattern language |
I.H.v | specified (III.4) |
WHEN type-check / conditional compilation |
I.K.iii, I.H.vi | specified (III.4) |
| Class / interface / struct / enum / group / namespace | I.K | specified (III.6) |
Methods, INIT, params, RETURNS shapes |
I.K.vi, I.M.i | specified (III.6) |
Error handling — pipe-XOR RETURNS, PANIC/TRY/RECOVER |
I.M | specified (III.4, III.6) |
CONVERSIONS / OPERATOR AS/INTO hosts |
I.D.viii | specified (III.6) |
| Conditional-compilation platform arm; assertion args | I.H.vi, ENVZN_USER_GUIDE.md III.C | informal (flagged inline) |
End of the Envzn Language Constitution, Version 2.0.
This document was restructured on 2026-05-18 into the articles described in the opening section: a normative language specification, a lowering reference, a developer guide, and a roadmap. The ?-type-suffix and the optional-type model were swept out in the same pass, leaving the bare-type-with-EMPTY absence model of I.D.iv as the single account of absence. Article I is authoritative where the articles disagree.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
APPENDIX — RESERVED IDENTIFIERS CATALOG
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
This catalog is the single reference for the reserved keywords, the primitive type names, the kernel classes, and the identifiers reserved against a future version. It is a catalog rather than prose by intent — it exists to be scanned. The authority for it is the live compiler: bin/compiler/parse/tokens.py, bin/compiler/primitives.py, the reserved-name set in bin/compiler/module_driver.py, and the kernel source under kernel/. This appendix mirrors that state and adds the V2 and V3 reservations the parser refuses today; any change to the compiler sources named above is reflected here in the same commit.
Language keywords (live)
Sourced from bin/compiler/parse/tokens.py.
- Declaration and module structure:
CLASSNAMESPACEEXTENDSIMPLEMENTSINTERFACESTRUCTENUMGROUPMETHODINITCLEANUPMODULEVERSIONINTERNALALIASFOREIGNFOREIGN_TYPEBINDSETS_ERRNOLOADCALLED_BY_EMITTERUNSAFETEMPLATEGIVEN. (ALIASis the file-scoped dependency import —ALIAS Mod::Type;BIND/SETS_ERRNO/CALLED_BY_EMITTERare the FOREIGN BIND FFI surface of I.N.h.) - Type-system modifiers:
ABSTRACTFINALOVERRIDEPRIVATEPROTECTEDSHAREDVOLATILEVALUEHIDDENREFERENCEMUTABLEMOVEDUPLICATECONSTANTMODIFYAUTODERIVEDBLITTABLEDEPRECATED. (VALUEmarks the value-semantics class kind of I.J;HIDDENis the implementation-hiding class prefix of I.K;DUPLICATEis the explicit deep-copy value operator, family ofMOVE/CREATE;DERIVEDmarks the computed serialization field of I.P;BLITTABLEis a template-qualifier atom (V1 Part I), reserved so it reads as a qualifier, never a user type name.) - Method semantics and instance:
RETURNSRETURNVOIDLAMBDAOFISCREATESELFSUPER - Control flow:
IFTHENELSEWHILEDOFORLOOPREPEATUNTILTOSTEPINBREAKCONTINUEMATCHWHENDEFAULTWITHINDEXLABEL(FOREACHis reserved-only — see the Reserved-only bullet below) - Error handling and STATUS narrowing:
TRYRECOVERFINALLYPANICASSERTASSERT!UNREACHABLE!SNAPSHOTSTACKTRACESUCCESSFAILUREPARTIAL_SUCCESSOKPARTIALVALID. (PANIC/RECOVERrenameTHROW/CATCH, 2026-06-26;THROW/CATCHstay reserved only to emit a "renamed" error.ASSERT!/UNREACHABLE!shipped V1; bareUNREACHABLEis likewise reserved, only so the parser can hint "did you meanUNREACHABLE!". Reserved-with-no-semantics:NO_RETURN(V3),FREEZE,POISON,FENCE.) - Logical and boolean literals:
ANDORNOTXORTRUEFALSEEMPTYNONE. (NONEis the absence spelling that survives besideEMPTY: theMATCHabsence arm acceptsNONE/EMPTY, the legacyIS NONEreads asIS NOT VALID, andREFERENCE T? x = NONEis the deferred-init reference pattern — the canonical absence model remains bare-typeEMPTY, I.D.iv.) - Bitwise and shift (keyword form — no C-style operator symbols):
BANDBORBXORBNOTLSHIFTRSHIFTASHIFTLROTATERROTATE - Bit-pattern equality:
BEQUALSBNEQUALS - Concurrency:
SYNCHRONIZED - Conversion operators (I.D.viii):
CONVERSIONSOPERATORASINTOFROM - Reserved-only (claimed, no V1 semantics — using one is an error, E1163):
NO_RETURN(verified-divergence method modifier, V3) ·FREEZEPOISONFENCE(low-level LLVM vocabulary) ·FOREACH(withdrawn from V1 2026-08-18 — was a bare synonym ofFOR … IN; reserved for a possible strided form in V3, DESIGN_QUEUE #68). These are claimed so no program can bind them as identifiers.
Special $-prefixed tokens
The dollar-prefixed family is small and consequential — each token names a slot the compiler synthesizes for the developer to read, and each is recognized only in a specific syntactic position. The tokenizer treats each as a single token (bin/compiler/tokens.py), not as $ followed by a separate punctuation character; mixing them outside their position is a parse error.
| Token | Purpose | Where it's valid | Spec |
|---|---|---|---|
$= (alias: $RETURNED) |
reads slot 0 of a pipe-XOR call's success tuple — shorthand for $=[0] |
the success (THEN) branch of an IF/WHILE pipe-call only |
I.M.i |
$=[i] (alias: $RETURNED[i]) |
reads slot i of the success tuple, where i is a compile-time integer literal |
same as $= |
I.M.i |
$! (alias: $ERR_MESSAGE) |
reads the failure STATUS's message — a String; pairs with $# as the two failure-side accessors |
the failure (ELSE) branch of an IF/WHILE pipe-call only |
I.M.i |
$# (alias: $ERR_CODE) |
reads the failure STATUS's int32 error code (FAILURE("...", code)); paired with $! |
the failure (ELSE) branch of an IF/WHILE pipe-call only |
I.M.i |
$? |
explicit discard of a return value — "I know I'm dropping this and I mean to" | single-return discard $? := call(), or a slot in a multi-value return destructure; never a call argument (E1136) or a parameter name (E1137) |
I.J.i, I.M.i |
$* |
a foreign BIND return marker meaning the returned C string is heap-owned and the compiler must ::free it after copying |
trailing the RETURNS String clause of a FOREIGN BIND declaration |
I.N, ENVZN_IR_SPEC.md C.vi |
$(freer()) |
the named-freer variant of $* — the freer to call instead of ::free |
same as $* |
ENVZN_IR_SPEC.md C.vi |
$_ |
RESERVED, no live form. Was the anonymous loop variable of LOOP $_ IN …; that form was withdrawn 2026-08-18 with the rest of the old LOOP surface (I.H.iii(d)). The token stays claimed so it is not reused for something else. |
nowhere — the form it belonged to no longer exists | I.H.iii(d) |
Two policy notes. $? and $_ are distinct tokens with distinct meanings — $? discards a return-slot value, $_ is the anonymous loop variable; one does not substitute for the other, and the lexer does not accept either form in the other's position. $_ is WITHDRAWN, not deferred (changed 2026-08-18). It was previously described here as "deferred in the tokenizer", which read as a gap waiting to be closed; it is not. The LOOP $_ IN form it belonged to never tokenized (gh #159) and has been withdrawn along with the rest of the old LOOP surface, so there is no deferral left to clear and no examples to reach for. Collection iteration is FOR … IN; iteration that needs the element's position is LOOP … WITH INDEX (I.H.iii(d)). The token itself stays reserved so a future feature does not silently inherit a spelling readers may still associate with loops.
Blittable types
The value-array of I.J.i admits exactly the blittable element types — those whose value can be transferred between storage locations by a flat byte-copy, without aliasing, ownership transfer, or any other lifetime concern. The set is closed under the following rules, applied transitively:
| Rule | Type | Blittable? |
|---|---|---|
| 1 | Any primitive listed above (int*, uint*, float*, boolean, char, binary, and the value-class numeric primitives number / complex) |
yes |
| 2 | Any String-family type (String, DynamicString, ByteBuffer, DynamicByteBuffer) |
no — a String is stored as an owning handle (_ev_unique<String>), so row 5 applies. (This row said yes until 2026-07-14; it contradicted row 5 and mis-routed a String-bearing STRUCT to the value-array backing, where it could not compile at all.) |
| 3 | A STRUCT whose every field is blittable |
yes |
| 4 | A VALUE CLASS whose every data field is blittable and that declares no EXTENDS — its methods and any IMPLEMENTS do not affect blittability, since a value class's value lives entirely in its stored fields. (A plain heap CLASS is never a value-array element: its instances are owning handles, so a CLASS[] is always the object-array.) |
yes |
| 5 | A handle field, a REFERENCE field, or a field of a parametric type carrying handles |
no |
| 6 | A Box[T], Array[T], Dictionary[K, V, H], or any other heap-with-handles collection |
no |
| 7 | A value-array — T[] or T[N] — as a field of a STRUCT or CLASS whose other fields are blittable |
yes (transitive) |
The blittability of a type is computed once by the analyzer (blittability_diagnostics.py) and cached against the type's symbol; no source-side annotation is required, and the developer never writes "blittable" or any equivalent keyword. A T[] is legal for every element type T (I.J.i (a), (c)): a non-blittable element simply selects the object-array backing rather than the value-array, so there is nothing to reject. (The former E2098 — "value-array element type is not blittable" — was retired with the object-array migration of 2026-06-08 that made T[] universal; the code stays reserved.)
A blittable type carries no requirement on how its bytes are laid out, beyond what the underlying C++ compiler chooses. The "blit" of "blittable" is the bulk byte transfer, not a guarantee of trivial layout — the four String-family types are blittable because their copy semantics are well-defined deep copies of their internal storage, even though that storage is heap-backed. The value-array's lowering carries the same per-element copy through every slot.
Primitive types
Sourced from bin/compiler/primitives.py.
| Canonical | C++ lowering | Aliases |
|---|---|---|
int8 / int16 / int32 / int64 |
int8_t … int64_t |
i8 / i16 / i32, int, integer / i64 |
uint8 / uint16 / uint32 / uint64 |
uint8_t … uint64_t |
u8 / u16 / u32, uint / u64 |
int128 |
__int128 (compiler built-in; no header) |
i128 |
uint128 |
unsigned __int128 (compiler built-in; no header) |
u128 |
float32 / float64 |
float / double |
float, f32 / double, f64 |
boolean |
bool |
none — the abbreviated bool is not an alias |
char8 / char16 / char32 |
char8_t / char16_t / char32_t |
c8 / c16 / char, character, c32 — char and character alias the code-point width char32 (the three-width family of I.D.i(d)) |
binary |
uint8_t |
none — the sole octet type, distinct from uint8 because an octet is not a number |
decimal128 |
Decimal128 value-class ({ int128 coefficient; int32 exponent }) |
decimal |
opaque |
_Opaque (compiler-known special class; V1 Part G) |
none |
C.size_t |
size_t (verbatim) |
none — boundary-only, see I.D.x |
C.ssize_t |
ssize_t (verbatim) |
none — boundary-only, see I.D.x |
C.long |
long (verbatim) |
none — boundary-only, see I.D.x |
C.unsignedLong |
unsigned long (verbatim) |
none — boundary-only, see I.D.x |
The V1 design adds two special value-type primitives — number (an int64-or-float64 tagged value) and complex (a pair of numbers; imaginary unit im) — backed by compiler-known Number / Complex special classes and never instantiated directly (both shipped in V1 on 2026-06-23 — value-classes over the kernel UNION construct, Number.ev/Complex.ev + their conversion hosts). This supersedes the earlier fixed complex_float32 / complex_float64 primitives (and their i literal suffix): the old complex_float* / complex_int* names are retired in favour of the single complex. decimal128 (alias decimal) is also a live V1 primitive (promoted from reserved 2026-06-28, primitives.py state="active"): an IEEE 754-2008 exact base-10 numeric backed by the compiler-known Decimal128 value-class ({ int128 coefficient; int32 exponent }, value = coefficient × 10^exponent), never dev-instantiated. Context-typed literals (decimal128 price = 19.99 — no d suffix), exact + − * / with round-half-even, cohort-preserving so it is Comparable + Hashable (a valid Dictionary/Set key + SortedList element), the RoundingMode rounding-context surface (roundTo, add/subtract/multiply/divide), AS/INTO conversions including the IEEE BID 16-byte wire codec (decimal128 INTO ByteBuffer / ByteBuffer INTO decimal128), and ≈/≉ relative approximate-equality. Reserved against use in V1, each a compile error: the arbitrary-precision bigint and rational; and the sub-byte bit. The wider integers int128 / uint128 are live V1 primitives (promoted from V2 and implemented 2026-06-02; primitives.py state="active", lowering to the __int128 / unsigned __int128 built-ins) — see the Primitive types table above. They support + - * / %, comparison, and the keyword bitwise operators; the INTO String operators (on NumberConverter), the String INTO int128 / uint128 parses, and Format render them via hand-rolled 128-bit decimal/base conversion (no libc path). The power operator ^ and the postfix factorial ! are available on the numeric types (shipped 2026-06-02 — see the operators of I.F.ii). Distinct from all of the above are the four C-ABI boundary types — C.size_t, C.ssize_t, C.long, and C.unsignedLong (shipped 2026-08-06, primitives.py kind="c_abi", state="active") — which lower verbatim to the C spelling they name and exist solely to type a foreign-function boundary. Each is a single reserved word that contains a dot; C is not a namespace and reserves no identifier, so a class or module named C stays legal. They are admissible only inside a FOREIGN/FOREIGN BIND declaration, carry no arithmetic and no ordering, are not members of Numeric, and have no guaranteed width — that absence is their contract, since a fixed-width primitive cannot express a pointer to size_t (size_t is unsigned long where uint64_t is unsigned long long, and which of them matches differs by platform). See Part I.D.x.
Kernel module — ENVZN
Authored as roughly one hundred ten .ev source files; the compiler emits a per-class header for each, alongside a hand-written floor and its native glue. The // AUTO-GENERATED first-line marker, not any fixed count, distinguishes an emitted header from a hand-written one.
- Collections:
ArrayDictionarySetBoxStackQueueDequeLinkedListSortedListOwnedList - Dictionary implementations:
ChainedHashDictionaryRedBlackTreeDictionaryProHashDictionary— pure-Envzn, behind theDictionaryinterface - Iterators:
ArrayIteratorDictionaryIteratorLinkedListIteratorLinkedListNodeSetIterator - Strings and buffers:
StringDynamicStringByteBufferDynamicByteBuffer - System surface:
SystemStdioFilePathMathFormatter— andConvert(gateway retired 2026-06-07; name still reserved) - Conversion hosts (replace
Convert):NumberConverterFloatConverterCharConverterCharWidthConverterPrimitiveConversionsTextConverter(AS/INTOhosts);NumericUtilitiesHexCodecBase64CodecByteOrderCodecCharClassifier(NAMESPACEmethod hosts);DateTimeFactory(construction singleton) - Hashing:
Hasher OF K(the pluggable strategy interface) with the shipped hashersDefaultHasher,StringHasher, andSipHasher - Date and time:
DateTimeTimeDurationMeasuringTimer— constructed throughDateTimeFactory; the ring's own formatting and parsing (see I.O) - Randomness:
CasualRandomSeededRandomSecureRandomXoshiro256(engine)RandomError - Error hierarchy:
Error(parent)CompileErrorFileErrorIndexOutOfBoundsErrorMathErrorNetworkError - Interfaces:
CloneableIterator OF TBidirectionalIterator OF THashableEquatable[T]Comparable[T]Arithmetic[T]Multiplier[T]ReadableWritablePrintableSerializableJSON_SerializerThreadStarterShareableShufflerRandomShuffleable - Enums:
DeliveryModeSeverityBuildEncodingEndiannessTimeComparison
Reserved module names
A module manifest's name field may not equal any of: System Stdio Process Math Convert File Path.
Reserved for V2 and beyond
Recognized by the parser and rejected with a "not yet implemented" diagnostic, so that V1 code cannot shadow a name a later version needs.
- V1 concurrency surface (shipped — see I.L): live keywords
CONCURRENTPARALLELSYNCHRONIZED; classesTaskChannel[T]Future[T]Mutex[T]Broker[T]Subscription[T]WorkerPool+ the concreteAtomicXfamily; injected referencecurrentTask. Reserved for V2 (not yet live): keywordsDETACHED(DETACHED CONCURRENT),ATTACHED/LONG_LIVED/TEMPORARY(pool/scope modifiers); classesUnboundedChannel[T], the parametricAtomic[T]sugar;AUTO SYNCHRONIZED CLASS - V2 concurrency pattern library (pending — see I.L):
Pipeline[In, Out]ScatterGatherForkJoinActor[State, Message]SupervisorRateLimiterCircuitBreakerBulkheadFlow[T] - V2 classes:
Cursor[T]·Format·Pair·PriorityQueue·Logger - V3 UI layer:
BUTTONCHECKBOXCOLUMNDROPDOWNHSTACKIMAGELABELLISTSCROLLSEPARATORSPACERTABLETEXTFIELDVSTACKWINDOW, and the V2-reserved-onlyCHARTDATEPICKERMENUBARMODALPROGRESSBARSLIDERSPLITVIEWTABSTOOLBAR - V3 networking and GPU:
TCPConnectionTCPServerWebSocketSTREAMGPU_Operation, and the GPU keyword familyGPUGPU_ASYNCGPU_RECEIVEGPU_SEND - Adjectives / marked-data family (Part C): keywords
DIRTYSECRETENCRYPTED— already reserved in the tokenizer (alongside liveUNSAFE) so user code cannot bind them; their semantics ship with the Adjectives feature PUBLIC— reserved, and deliberately unrecognized. The token is inbin/compiler/parse/tokens.py, soPUBLICmay not be bound as an identifier, but no declaration parser accepts it: it is absent from the field-modifier set (parse/parser/decl_class.py) and from the method-modifier set (parse/parser/decl_method.py), so writing it is a parse error (E1008in a class body) rather than the "not yet implemented" diagnostic the rest of this section describes. That is by design, not an omission. Public is the space a declaration occupies by saying nothing (I.K.iii(b)) — a field,CONSTANT, or method carrying none ofPRIVATE/INTERNAL/PROTECTEDis already public — so a keyword for it would change no program's meaning while putting a modifier on every declaration and burying the ones that restrict. The reservation is kept so the word cannot be bound to something else, and so a later version keeps the option of giving it a meaning that is not merely the default spelled out. Until then this Constitution writes public visibility as the absence of a modifier and showsPUBLICin no example.
Retired by V1 concurrency rebuild (see I.L)
Recognized by the parser and rejected with a diagnostic that names the replacement, so that pre-redesign code surfaces its migration site clearly.
- Classes / singletons:
ThreadSwitchboard(→Broker[T]),Threadas user-facing class (→CONCURRENT { … }block +Taskruntime reference),ThreadInbox(→Channel[T]+ scope-bound subscription) - Methods:
->send_message(value, mode)(→Channel.send/Broker.publish),ThreadSwitchboard->register(→Broker.subscribeinside aCONCURRENTscope),currentThread(→currentTask) - Enums:
DeliveryModeand its casesPOINT_TO_POINT/BROADCAST(→Channelis point-to-point by type;Brokeris broadcast by type) - Block forms:
WAITblock against a registered channel (→Channeliteration orBroker.subscribeblock)
The ~/ floor-division operator is implemented. TAINTED and Sanitiser[T] were dropped 2026-06-05 (Part C is now the Adjectives feature); the Part C identifiers queued for V1 are ADJECTIVES, CARRIES, MARK, CLEAR, not yet in the tokenizer.
━━━━
(b) Rule. Every WHEN arm names one alternative of the subject's closed set and binds what that alternative carries, for that arm only. A MATCH whose arms do not cover every alternative — an uncovered GROUP member, ENUM case, or STATUS kind — is a warning: for a GROUP the alternatives are its written members, which is also its transitive set, because only a group whose members all have one concrete representation can type a value at all (I.J.v(a.i)) — the set is closed, so a gap is usually an oversight, but leaving one deliberately unhandled is a legitimate choice the compiler flags rather than refuses. A possibly-EMPTY subject is not matched at all — absence is IS VALID / ?? per I.D.iv, and dereferencing an unproven value is E2080 wherever it occurs.
Example.
MATCH shape { // GROUP Shape { Circle, Square, Triangle }
WHEN Circle c: { ... }
WHEN Square s: { ... }
} // WARNING — Triangle uncovered
MATCH direction { // ENUM Direction { NORTH, SOUTH, EAST, WEST }
WHEN NORTH: { ... }
WHEN SOUTH: { ... }
} // WARNING — EAST, WEST uncovered
Rationale. The binding arm is the reason the construct exists: it yields a value — narrowed instance, associated payload, status message — that the analyzer can then trust. Coverage over any closed set is checkable, so the compiler reports the gap; it warns rather than errors because deliberate partial handling is a real pattern and the fallthrough is silent, not unsafe.
(b.i) Rule. A MATCH is a statement and does not itself yield a value; its arms read as ordinary bodies. A WHEN carries no guard — a runtime test that further refines an arm is an ordinary IF inside that arm's body.
Example.
MATCH request {
WHEN Order o: {
IF o->isPaid() THEN { ship(o) } ELSE { hold(o) } // the guard, written plainly
}
}
Rationale. Guards were part of the value role removed in (a.i) and share its fate: a guard is an IF folded into the arm header, where it reads as part of the pattern rather than as the branch it is. Written inside the body it is the same code, one construct fewer, and the arm's binding is already in scope.