Conventions of this specification

This file is about the specification rather than about Zelkova. It is neither a chapter nor an appendix — nothing in it says what a program means — and it is not itself normative. What it holds is the set of rules a chapter is held to, and the reasons those rules are what they are.

The index is what a reader of the language wants: what the spec is, the three lead-ins that appear in chapter prose, and the chapters themselves.

The expect= vocabulary

Every fenced ```zel block in a chapter carries an expect= tag in its info string, and cargo test --test spec runs every one of them through the compiler. A zel block with no tag, or with a tag the harness does not recognise, is a hard test failure — never a silent skip. When you add an example, tag it.

TagMeaning
expect=okParses, canonicalizes and type checks with no errors. The typer checks the declarations it can translate and leaves the rest unchecked, raising no error for them: an expression form the term language does not model, a reference to a declaration of the same module written without an annotation, and a module foreign block whole. So the tag promises that every declaration the typer reached honours its annotation, not that it reached every declaration — about one in nine of the declarations with a body across today's chapters is skipped, each of them for reaching an unannotated declaration. Exhaustiveness is not run at all: it is a stub that accepts every module. In a package= group, a block that imports from a failing block of the group does not pass either.
expect=parse-errorFails somewhere in the parser (tokenizer, layout or grammar). Which specific error is not pinned. Use when the chapter claims only that the source is rejected.
expect=parse-error:ReasonThe same, and the reason must match. Reason is either the phase (Tokenizer, Layout) or one of the nineteen specific errors — CharNotClosedError, StringNotClosedError, InvalidEscape, UnicodeError, IndentationError, TabError, UnrecognizedToken, IntegerOverflow, MultipleDecimalPoints, NonAsciiDigit, MalformedNumber and UnclosedBlockComment from the tokenizer; LayoutError from layout; InvalidToken, UnexpectedEOF, UnexpectedToken, ExtraToken, InfixPrecedenceOutOfRange and SpacedDot from the grammar — matched against the real enums in crates/zelkova-syntax/src/parser/. Use whenever the chapter's prose describes the error the reader will see. One of the nineteen is unusable: MalformedNumber exists for a tokenizer defect, and no source reaches it.
expect=canonical-error:VariantNameParses, then canonicalization returns a Vec<canonical::Error> containing at least one error of variant VariantName — matched against the real variant names in crates/zelkova-compiler/src/canonical/mod.rs's Error enum.
expect=type-errorParses and canonicalizes, and then the type checker returns at least one error. Which one is not pinned. Use when the chapter claims only that the declaration is a type error.
expect=type-error:KindThe same, and the kind must match — Kind is one of the variant names of ErrorKind in crates/zelkova-compiler/src/typer/mod.rs, matched against the real enum. Use whenever the chapter's prose describes the error the reader will see. A block the parser or canonicalization rejects fails both of these tags rather than satisfying them: the tag names the phase that decides the rule being claimed.
expect=unimplementedMust fail somewhere in parse, canonicalize or type check, but deliberately does not pin which error: pinning would wire tokenizer/grammar internals into a prose document, and the tag's whole job is to go red the day the feature is actually implemented. On an expected failure the test run prints the error it observed, so a reviewer can eyeball that the block failed for the reason the chapter intends.
expect=dependency-errorThe block's package (see below) has no valid module order — its imports form a cycle — so nothing in it is canonicalized at all. The one expectation that belongs to a group rather than to a module: every block of the package carries it, or none does.
expect=fragmentAn illustrative fragment, deliberately not executed. The only opt-out, and it must be written explicitly — there is no implicit skip.

A fenced block whose info string's first token is not zel (```sh, a bare ```, prose) is not touched by the harness at all.

The expect=parse-error:Reason row above is read by crates/zelkova-compiler/tests/spec.rs, which checks it against the reasons the harness really accepts: keep every reason name in it backticked, and keep the count of specific errors spelled out as a word.

More than one module: package=

A block holds a single module, and by default it is compiled alone, as a package of one. A block may also carry a second tag, package=<label>, beside its expect= — an info string reading ```zel expect=ok package=alias.

Either way, the package is compiled against one interface it did not write: std/core's own Basics, with its operators taken out and with the modules it is built over in reach, which is what lets a block name Int, Float or Bool without declaring or importing them. A package of one gets exactly the interface a package= group does.

A package whose modules name one of the eight default imports — Basics itself, or List, Maybe, Result, Task, Char, String or Tuple — is the same case a real module of that name is: it receives none of the eight, and gets the five scalar type names directly instead of importing them. module Task exposing (Failure(..)) is such a block today; naming any of the other seven has the same effect. Such a package is compiled as zelkova-core, the package the eight belong to, so a Basics it declares is where the scalar types are declared; every other block belongs to an ordinary package.

Blocks sharing one label, within one chapter, are one package. They are parsed together, ordered by their imports, canonicalized in that order against each other's Interfaces, and then type checked in the same order — which is how a chapter shows two modules at once. Each block keeps its own expect=, so an example can show one module compiling and its importer failing, and the failure is reported on the importer's line rather than on the group.

A module with an error still publishes its interface to the rest of the group, so an importer of it goes on resolving every name it imports. An interface carries the declared signatures canonicalization validated, and a declaration whose own canonicalization failed is in it by its annotation when that annotation is sound. A block that imports from a failing one is not reported for a name the failure explains, and so it does not pass expect=ok: its declarations were left out, not checked. Withholding the interface would turn an error in one block into a wave of unresolved names in the next.

SPEC-3 settled this, for the Modules, exposing and imports chapter, which cannot be written one module at a time. Three alternatives were considered and rejected: adjacent blocks sharing one expectation (a group can then only say "something failed", not which module), a hand-built Interface in crates/zelkova-compiler/tests/spec.rs (the other module never appears in the chapter, so the reader cannot see it), and writing the group to a temp directory for compile_package (slow, touches disk, and prints status lines on every spec run).

Four things a group cannot do, each a hard failure rather than a skip, because none is expressible once the group is compiled as a unit: hold a parse-error expectation (the group is parsed as a whole before any of it is compiled), hold an expect=fragment, contain a block that fails to parse, or declare one module name twice. A rejected-source example belongs in a block with no package= label.

Tag every claim the chapter makes

A chapter that describes a known-bad diagnostic is making two claims, and they have different lifetimes. The rule — this source is rejected — is permanent. The sentence describing today's error is temporary, and it becomes false the moment someone improves the diagnostic.

The Layout chapter has two of these: a case branch indented deeper than its siblings (ERR-11) and leading indentation before module (ERR-12). Rejection is the intended behaviour in both cases and what happens today, so a bare expect=parse-error stays green straight through those fixes — and the prose explaining that the grammar "trips on the second ->" would quietly become a lie.

So pin the reason: those blocks are tagged expect=parse-error:UnexpectedToken, naming the wrong-but-current error deliberately. When ERR-11 lands and the error becomes a proper LayoutError, that block goes red, and whoever fixed the diagnostic has to update the paragraph describing it in the same change.

Two consequences, both deliberate. First, this reads as being in tension with A spec change and a semantics change do not share a diff, below — pinning the reason guarantees that whoever fixes ERR-11 edits docs/spec/layout.md in the same PR to get green. That is the wanted outcome and not the shape that rule is aimed at. What it forces is a small, prose-only edit, written by the one person who has just read the code the paragraph describes; what the rule forbids is deciding what the language is inside the diff that changes what the compiler does. A red block is the mechanism that makes the first happen; the rule is what stops it becoming the second.

Second, the guarantee is narrower than "the stale sentence cannot survive". The pin is on the error variant only, so an ERR-11 fix that produced a different but still UnexpectedToken error would leave the block green with the stale paragraph intact. Pinning the token as well is possible and is deliberately not done: wiring that much grammar detail into a prose document costs more than the residual risk is worth.

The general form: tag every claim you make, at the granularity you make it. Claim only rejection, and use the bare tag. Describe the diagnostic, and pin it. There is no manual verification step here on purpose — a convention that depends on someone remembering to check something by hand is one that will be skipped, and a spec whose examples are checked by ritual is not checked at all.

A block tagged this way still looks, at a glance, like an ordinary green example — expect=ok and expect=parse-error:UnexpectedToken are exactly the tags a correct example would carry too, and the only thing marking the block as current-but-wrong is prose a skimming reader can miss. So the sentence that says so opens with a fixed, bolded lead-in: Known gap:. That makes it something a reader — or grep -r "Known gap:" docs/spec/ — can find without reading every paragraph, and it is what tells a future session not to treat the block's shown behaviour as what the language requires. expect=fragment doesn't need it: the tag itself already says the block isn't normative.

A expect=unimplemented block is the same kind of risk from the other direction: the tag says the example doesn't compile yet, but prose right next to it can still describe design intent — a rule the language will have once the feature exists — in a way that reads as settled fact. That prose gets its own lead-in, Not implemented:, for the same reason: grep -r "Not implemented:" docs/spec/ finds every place a chapter is describing a feature ahead of the compiler rather than behind it. The let … in section of layout.md is the first example of both lead-ins living in one section.

The third lead-in, Provisional:, belongs to the appendix and a chapter never carries it. It is what the expect= tags buy a chapter, done in prose: an appendix cannot be held to account by a test, so it says in its own text which of its claims have nothing behind them. A chapter has no need of it, because a language question with no settled answer is an Open question at the foot of the chapter instead — a language may have questions it has not answered, but it cannot have a rule that is only provisionally a rule and still be one thing.

The words a chapter uses

The rules above are about the half of a chapter a test reads. Nothing reads this half, so it is written down here: the vocabulary the chapters already use, recorded rather than proposed. Every rule below is one that reading the chapters would teach, and none of them makes an existing paragraph wrong.

A rule a program has to follow is written must, cannot, may not or is an error. Those are one force spelled four ways, and choosing between them is grammar rather than degree. must and cannot take the construct as their subject — leading whitespace on a line must be an even number of spaces, a qualified name cannot be shadowed. Is an error takes the situation instead, which is what a sentence wants when the thing ruled out is a combination rather than a construct: repeating a name is an error. Is rejected is the same claim told from the compiler's side, and reads naturally as the line introducing a block. Nothing separates the four in strength, because there is no weaker degree for them to be stronger than: a chapter states rules, never advice a program may decline.

may grants a permission — a name may mix scripts freely after its first character — and is the word for a latitude a reader would not otherwise assume. never is its mirror, prohibiting something of the language rather than of one program: thirteen words are reserved and may never be used as identifiers.

should is reserved, and never states a requirement on a program. It belongs to the sentence a Known gap: or Not implemented: lead-in opens — that block should be rejected and is accepted — and to the continuations of such a paragraph. That is prose about the compiler: the language requires something and the binary does not do it. A rule for a program is written with the words above, and reserving a common English word for the compiler-facing case is worth its cost, because a rule written in that voice reads as one the reader may weigh against others and this document holds no such rules.

Which is also why the obvious import — RFC 2119's MUST / SHOULD / MAY — would be wrong here, worth writing down so that the next reader does not have to re-derive it. RFC 2119's SHOULD means recommended, and a valid implementation may decline, the opposite of what should means in a Known gap: sentence, where the thing is required and the compiler is at fault. Taking the standard meaning would either invalidate every sentence of that shape or, worse, leave them readable both ways. The other half of that convention's appeal is letting a reader tell a guarantee from guidance, and a chapter has no guidance in it to tell apart.

None of these words obliges an example. Most rules here carry no block at all — whole sections of name resolution and packages state theirs in prose alone, and a rule is no weaker for it. What holds is the converse: where a block is the demonstration of a rejection claim, it carries a rejection tag — expect=parse-error, expect=parse-error:Reason, expect=canonical-error:Variant, expect=type-error, expect=type-error:Kind, expect=unimplemented or expect=dependency-error — and not expect=ok.

The exception is the case the lead-ins exist for, and it is common: an expect=ok block sits under a rejection claim in most chapters, and every one of those blocks has a Known gap: or Not implemented: paragraph beside it — before or after — saying so. The rule is the language, the green block is the binary, and the lead-in is the whole of what separates them. Nothing else can be: expect=ok beside is an error is exactly as green as expect=ok beside is accepted, which is the one contradiction in a chapter the harness cannot see.

The sentences a chapter does not need

The section above decides which word carries a rule. This one is about the sentences standing around it, and it is here because a first draft grows the same handful of them every time. Nothing reads this half either — no test sees a paragraph that says its point twice, and the patterns below are what a drafting session produces when it is thinking about being convincing rather than about being read.

Two of the patterns belong to the next section, which owns them: commentary on the document, and a case made for a rule rather than the rule itself. The rest are these.

A sentence opens on its subject. A user can mark a Zelkova module as a JavaScript interface. This is done by using the javascript modifier is one sentence wearing two: a user marks a Zelkova module as a JavaScript interface with the javascript modifier. A chapter has no need to announce a claim before making it.

A negation that only mirrors the first half is dropped. A closing rather than X, and not X, instead of X earns its place when X is a thing a reader might otherwise have assumed, and not when it merely says the first half again in the negative: an error at the boundary rather than a wrong answer somewhere further on is an error at the boundary. rather than, not as and instead of are the three spellings, and they are greppable.

A paragraph does not end on its own point. …which is the property this design exists to preserve, which is what makes the rule worth having. The paragraph made the point; a restatement in the last clause reads as a chapter unsure the first one landed.

A cost is stated and then left alone. Naming what a rule costs is worth doing; following it with the reason the cost is smaller than it looks is not. The reader weighs it.

One shape per paragraph. Three is what sentences in a row, or a which means … which means chain, is a template rather than a thought. Vary the weakest, or cut it.

A conclusion is stated in one place. When two sections end on the same sentence, it stays where the rule lives and the other links to it by anchor.

Structure carries the same bias: what a construct can do comes first, what it cannot comes after and shorter. Separate sections that each say what is forbidden merge into one — the three prohibitions Foreign interoperability makes about a facade signature are one section, and shorter than the three were. A chapter written before this section was recorded may still carry any of these; the prose-pass skill (.claude/skills/) is what takes them out, one pattern swept at a time.

A chapter says what the language is

Every sentence in a chapter describes Zelkova as designed, in the present tense. Three things therefore never appear in one:

Explaining why a rule is what it is stays in scope, and is much of what makes a chapter worth reading — the test is whether the reason is a property of the language ("allowing it would need a kind system") or a fact about the project ("SPEC-11 found the spelling carried no meaning").

The two lead-ins above are the exception, and both are about the compiler rather than the language: Known gap: describes behaviour that exists today and should not, Not implemented: a rule the compiler does not have yet. Those measure the distance between the spec and the binary, which is the one "not yet" a chapter is for.

A spec change and a semantics change do not share a diff

Writing a chapter surfaces compiler behaviour nobody intended — that is much of the value. When it does, file a ticket and specify the behaviour the language requires, tagging the example for what the compiler does today. ERR-11 and ERR-12 were both found this way. Fixing the compiler in the same diff that documents it makes the change unreviewable, and it is unnecessary: a spec claim the compiler fails is a red test, which is a working record rather than a lost one.

Chapter or appendix

The line between the two is what a claim changes. A rule that decides what a program means — what a name resolves to, what is visible across a boundary, what a manifest field obliges — is a chapter's, however file-shaped it looks. A rule about how bytes arrive, where they are kept, or what a command prints is an appendix's.

Neither is where an argument goes. A chapter explains why a rule is what it is only as far as the reason is a property of the language — "allowing it would need a kind system" — and stops at the point where the reason becomes a comparison with what was rejected. That is a third kind of document and it lives in docs/decisions/, which is not normative, is not checked for examples, and is where a chapter's rule may be cited from but never deferred to. The test is whether a later reader would otherwise re-open the question: Type classes states one derivation mechanism in full and is complete as it stands, and the survey of the eight it is not (DEC-1) is what stops someone proposing a generic representation again.

Writing a chapter

The write-spec-chapter skill (.claude/skills/) carries the method: probe the compiler rather than reasoning about it, settle the design questions with the owner before drafting, and file what turns up instead of fixing it. Each chapter also has a SPEC-n ticket, filed by a separate run before drafting starts, because that skill refuses to file the ticket it would later close.