rigid specification

The language reference for rigid v1.8.1. If you are learning the language, start with the tutorial — this page is the normative description.

status

Named after Kripke's rigid designator, a reference that picks out the same thing in every possible world — which is what a hash-pinned definition is. Encoding tags and diagnostic codes are frozen and append-only; everything else is subject to change while the identity model is validated.

1. Thesis

Text is authoritative for content. The derived graph is authoritative for identity. Ambiguity is a compile error.

A definition's identity is the SHA-256 of its canonical form, in which bound names are erased and every reference to another definition is that definition's hash. Names are a human-facing metadata layer. The compiler maintains an exact reference graph as a byproduct of compilation — never reconstructed, never approximate — and refuses to let a name silently change meaning.

Design ordering, used to settle every dispute: exactness > interop > ergonomics > expressiveness. Interop is why text, files, Git, and mainstream syntax survive. Exactness is why silent rebinding does not.

2. Lexical structure

Source files are UTF-8 with the extension .rg. One file is one module; the module id is its path relative to the workspace root, with forward slashes. Comments run from // to the end of the line and are preserved by rigid fmt, though they never affect identity.

String literals are double-quoted and support the escapes \n, \t, \r, \e, \", and \\. Integer literals are decimal and 64-bit signed. A digit run continues into a float only when a . is followed by another digit, so 3.14 is a float while x.field is a field access.

Reserved words

Keywords:

fn  let  return  while  if  else  import  from  table  dyn  memo  extern

Builtin names are reserved words too, so they cannot be used as identifiers:

print  input  read_file  write_file      // io
len  push  str  int  float  substr  chars  join // pure
fail  try                                // errors

3. Grammar

Deliberately tiny: definitions, calls, imports, arithmetic, and if-expressions.

module    := item* EOF
item      := import | fndef | tabledef | externdef

import    := "import" "{" ident ("," ident)* "}" "from" STRING ";"
fndef     := "memo"? "fn" ident "(" (ident ("," ident)*)? ")" effects? "{" stmt* "}"
externdef := "extern" "fn" ident "(" (ident ("," ident)*)? ")" effects? "=" STRING ";"
effects   := ("!" capability)+                        -- io fs net env time rand proc
tabledef  := "table" ident "{" (STRING "->" ident ("," STRING "->" ident)* ","?)? "}"

stmt      := "let" ident "=" expr ";"
           | ident "=" expr ";"                       -- assignment, locals only
           | "while" "(" expr ")" "{" stmt* "}"
           | "for" "(" ident "in" expr ")" "{" stmt* "}"
           | ifstmt
           | "return" expr ";"
ifstmt    := "if" "(" expr ")" "{" stmt* "}"          -- else is optional here
             ("else" (ifstmt | "{" stmt* "}"))?
           | expr ";"

expr      := cmp
cmp       := add (("=="|"!="|"<"|">"|"<="|">=") add)*
add       := mul (("+"|"-") mul)*
mul       := unary (("*"|"/"|"%") unary)*
unary     := "-" unary | "&" ident | dyncall | temporal
           | "print" "(" expr ")" | call

dyncall   := "dyn" "{" ident ("," ident)* "}" "(" expr ("," expr)* ")"
temporal  := "@" STRING "(" (expr ("," expr)*)? ")"

call      := ident "(" (expr ("," expr)*)? ")"        -- direct calls only
           | ident "[" expr "]" ("(" args ")")?       -- table dispatch
           | ident
           | primary

primary   := INT | STRING | "(" expr ")"
           | "[" (expr ("," expr)* ","?)? "]"                      -- list
           | "{" (ident ":" expr ("," ident ":" expr)* ","?)? "}"  -- record
           | "if" "(" expr ")" expr "else" expr
           | builtin

postfix   := atom ("." ident | "[" expr "]")*         -- field access, indexing

if appears twice above because it is two constructs. The parser tells them apart by what follows the condition: a { opens a statement block whose else is optional; anything else is the expression form, whose else is mandatory because it must produce a value.

Note what is absent: there is no break, no continue, and no && or ||. Named function definitions may not nest — a fn(x) { … } lambda is lifted out to the top level rather than nested (§20).

4. Values and operators

Six value kinds: Int, Float, Str, List, Record, and Fun (a reference created by &name).

OperatorsMeaning
+ - * / %Arithmetic. Two integers stay integral and division truncates toward zero; a float on either side promotes the other. Integer division by zero is an error, float division by zero an infinity.
+Also concatenates two strings, or two lists.
== != < > <= >=Comparison. Yields the integer 1 or 0. Equality compares numerically across Int and Float, so 1 == 1.0.
xs[i]Index a list, a string (yielding a one-character string), or a record by key.
r.fieldRecord field access.

float(e) widens an integer or parses a string; int(e) truncates toward zero. Two integers are never widened silently — that would lose precision above 253, which is the kind of quiet wrongness this language exists to refuse. Before hashing, -0.0 normalizes to +0.0 and every NaN to one quiet NaN, because content addressing cannot have two keys that compare equal or one that does not equal itself. The JS target maps Int to BigInt and Float to a plain number, because JavaScript's single number type is a float64 — using it for both silently rounded 64-bit integers, so node answered 9007199254740993 + 1 with 9007199254740992.

There is no boolean type: 0 is false and any other integer is true. Records are immutable maps whose identity ignores field order — the canonical form sorts fields by name, so two differently-ordered literals hash identically. Locals are the only mutable storage; definitions are immutable, and assigning to one is E0210.

Scope is flat per function: parameters, then lets, numbered in binding order, innermost match wins. A let inside a loop body keeps one stable slot across iterations, and reading it after a loop that never ran yields 0.

Value domains

rigid has no type system, but the four value kinds are distinguishable and the operators that accept them are not interchangeable. Three things are inferred: a parameter's domain, from uses admitting exactly one (-, *, /, % and ordering are integer-only; .field needs a record; substr/chars need a string; push/join need a list); a local's domain, from its initializer; and a definition's return domain, when every return agrees.

Three things are checked, and only where the domain is known: an argument at a direct call, an operand of an integer-only operator, and the base of a field access (E0212). Everything else infers nothing and reports nothing, so a parameter with no domain-forcing use stays polymorphic and a conflict silences a slot rather than failing it. Domains are inferred, never declared, and never enter canonical form.

Errors as values

fail(v) raises any value. try(e) evaluates e and yields { ok: 1, value: v } or { ok: 0, error: payload }. Every builtin runtime error — division by zero, out-of-bounds index, a failed int() parse, a missing table key, a quarantine violation — is catchable the same way.

Tests

A definition named test_* takes no arguments, must be pure, and passes by returning a nonzero value. Results are cached keyed by the test's content hash; since a hash pins the whole dependency cone, a cache hit is a proof that the rerun would be identical.

5. Effects and capabilities

A definition declares the parts of the outside world it may touch:

!io   !fs   !net   !env   !time   !rand   !proc

Order is free; the set is canonical, and fmt renders it in the order above. !io is rigid's own console and file builtins — print, input, read_file, write_file — and keeps exactly the meaning it has always had. The rest describe the host boundary and are declared on extern.

Capabilities are part of canonical form, so they are part of identity: a definition that gains !net gets a new hash, and an importer's sidecar sees drift. The sidecar also records the capability set at the moment of pinning, which is what lets the drift say which kind it is — W0405 names the widening, and --frozen refuses it. A dependency that starts reaching the network is a build failure, not an audit finding. rigid caps lists the whole set with the externs that supply it, complete by construction rather than by search.

The rule is one line: a definition must declare every capability it can reach. A violation is E0207, naming the capability that is missing. Propagation flows through dispatch tables and dyn sites — a site is !io if any declared target is — so a pure-looking dispatcher over a table containing one effectful handler is rejected. Temporal calls are always !io.

6. Quarantined dynamism

&name creates a first-class reference; creating one is a static edge in the graph. Reference values may be invoked only through a declared quarantine:

dyn { f, g }(callee, args...)

Each target resolves at compile time and is an edge at that site. At runtime the callee must be a member of the declared set, or evaluation stops with an error naming the value and the set. Calling a value outside a dyn block is E0204, and the compiler emits the inferred set as an I0405 hint.

Dispatch tables

A table is the named, data-keyed form of the same idea, and it is a definition: it hashes over its key-sorted entries, appears in deps and rdeps, and rides the sidecar rules when imported. name[key](args...) dispatches through it. The site's static edge is the table; the table's edges are its whole value set, so blast radius and effects flow through in two exact hops.

Keys are ordinary runtime data; the value set is closed. A missing key fails at the boundary and lists the available keys. Targets must be functions — direct calls of a table, dispatch through a non-table, and non-function targets are all E0208.

Argument counts are checked at compile time at every call form: direct calls, every declared dyn target, and every table entry (E0209). Arity is the first type in the language, admitted because it narrows the quarantine — violations that would have been runtime membership failures become compile errors.

7. Canonical form

Resolution precedes hashing. Every name occurrence resolves to exactly one binding:

canon(def) is then a tagged byte string, and hash(def) = SHA-256(canon(def)), computed in dependency post-order so every reference payload is already available.

8. Encoding table

Tags are frozen. Extend only by appending.

TagNodePayload
0x01Inti64 big-endian
0x2CTimeCallu32 length + hash string + u32 arg count + args — @"hash"(…)
0x2FFloatConvoperand — float(e)
0x30IfStmtcond + u32 then-count + then + u32 else-count + else
0x31Foru32 loop slot + iterable + u32 stmt count + stmts
0x32Closurereference to the lifted definition + u32 capture count + capture slots
0x2EFloat8 bytes, canonical IEEE-754 (−0.0 → +0.0, every NaN → one quiet NaN)
0x2DFnEff headeru32 capability mask + the 0x20 payload; used for any set other than {} or {io}, so pre-capability hashes never moved
0x02Stru32 length + UTF-8 bytes
0x03Localu32 slot
0x04Ref32-byte hash of the target definition
0x05SelfRef— (direct self-recursion)
0x06Calltarget (0x04/0x05 form) + u32 argc + args
0x07Binopu8 op tag + lhs + rhs
0x08Ifcond + then + else
0x09Negoperand
0x0ACREFu32 ordinal (intra-component reference)
0x0BFunReftarget reference (0x04/0x05/0x0A form)
0x0CDynCallu32 set size + set (sorted by encoded bytes) + callee + u32 argc + args
0x0DPrintoperand (io builtin)
0x0ETDispatchtable ref + key expr + u8 has-args (+ u32 argc + args)
0x0FLocalIndexlocal slot + key expr
0x10Letinitializer (bound name erased; slot is positional)
0x11Returnoperand
0x12ExprStmtoperand
0x13Listu32 count + elements
0x14Lenoperand
0x15Pushlist + element
0x16Recordu32 count + name-sorted (u32 len + name + value)
0x17Getbase + u32 len + field name
0x18Indexbase + key
0x19Input— (io builtin)
0x1AAssignu32 slot + value
0x1BWhilecond + u32 stmt count + stmts
0x1CFailpayload expression
0x1DTryoperand
0x1EStr()operand (int → decimal string)
0x1FInt()operand (parse; failure recoverable)
0x20Fn headeru32 param count + u32 stmt count + stmts (pure)
0x21Componentu32 count + members in order (SCC)
0x22Membercomponent_hash + u32 ordinal
0x23FnIo headeras 0x20, for !io definitions
0x24Table defu32 count + key-sorted entries (u32 keylen + key + target ref)
0x25Substrstring + start + count
0x26Charsoperand
0x27Joinlist + separator
0x28ReadFilepath (io; missing file recoverable)
0x29WriteFilepath + contents (io; returns contents)
0x2AModuleu32 count + sorted member hashes (names excluded)
0x2BExternu32 arity + u8 io + u32 hostlen + host path bytes

9. Invariants

What identity means, stated as guarantees:

The corollary that surprises people: names that are data rather than bindings are part of identity. Record field names and table keys are hashed; definition, parameter, and let names are not.

10. Cycles

Direct self-recursion in a singleton component uses SelfRef (0x05). Mutual recursion is legal: the reference graph is condensed into strongly-connected components (Kosaraju) and hashed in dependency order.

For a multi-member component every intra-component reference encodes as CREF (0x0A) plus a u32 ordinal. Member ordering must itself be name-free, so members are first encoded with placeholder ordinals (0xFFFFFFFF) and sorted by those bytes. Component bytes are then 0x21 + u32 count + members in order with real ordinals; component_hash = SHA-256(component bytes); and each member's identity is SHA-256(0x22 + component_hash + u32 ordinal).

known leak

Two structurally identical members of one cycle have identical placeholder encodings, so their ordering falls back to (module, name). The name layer touches identity for exactly that pathological case. Documented rather than hidden; revisit in v2.

11. The binding sidecar

Each module carries <module>.rg.bind.json, a resolution lockfile. It does not pin content — content evolves in text, where it belongs. It pins which definition each imported name means, by hash. It is diffable, mergeable, reviewable, deliberately shaped like Cargo.lock, and deliberately per-module so parallel edits do not collide in one giant file.

{
  "format": "rigid-bind/0",
  "definitions": { "norm": "sha256:…", "fib": "sha256:…" },
  "references": {
    "clamp": { "target": "util.rg#clamp", "hash": "sha256:…" }
  }
}

definitions lists the module's own definitions and is informational: it makes within-module drift visible in ordinary diffs and feeds tooling. references is normative.

The import statement names the target module; the sidecar pins the identity of what the name bound to there. Text can change freely; the sidecar is how the compiler distinguishes a name's meaning moving from its target being edited.

12. Reconciliation rules

For each imported name n pinned as hash h, checked against the target module's current name→hash table:

RuleConditionOutcome
R1n present, hash == hOK.
R2n present, hash != h, and h still exists under another nameE0202 resolution-changed. The name was re-used for a different definition. Hard error; never silently rebound. Typed fixes: rename the reference to the pin's new name, or explicitly rebind.
R3n present, hash != h, h goneW0401 drift. The definition was edited in place. Repinned in dev mode; an error under --frozen.
R4n absent, h exists under another nameE0201 + typed rename_reference fix. The definition was renamed, and a machine can apply the fix.
R5n absent, h goneE0201 plain.
R6no pin yetPin now; I0402 info.
R7the module has unresolved referencesIts sidecar is left untouched. Rewriting it would destroy the old-name-to-hash evidence that makes rename recovery possible.

R2 and R4 are the point of the language. R2 is the silent-rebind refusal: in a name-bound language, re-using a name quietly changes the meaning of every existing reference; here it cannot. R4 is rename detection by identity: the compiler recognises the renamed definition by hash and hands back a mechanical fix. I0406 applies the same recovery to same-module renames.

rigid fix --apply mechanically applies rename_reference fixes — only occurrences the resolver marked broken, so an unrelated local sharing the name is untouched — and iterates until check converges. rebind fixes are never auto-applied: accepting a changed meaning is a decision, so it is its own command. Under --frozen, any required sidecar write is an error, so the build is reproducible or it fails.

13. memo

memo fn f(a, b) { ... }

Results are cached under (definition hash, canonical argument encoding). Sound by construction: purity is required — if the dependency cone reaches !io the compiler emits E0211 — and the hash covers the entire cone, so any change that could alter the result changes the key. Staleness is impossible by construction rather than detected.

The cache persists across processes in .rigid-memo.json at the workspace root. The memo flag is an execution annotation, not meaning: toggling it does not move the definition's hash. dyn inside a memo function is sound, because the declared set is inside the hashed cone. The JS target runs memo functions uncached.

14. Temporal references

On every green check, each definition is written to .rigid-store/ under its hash: the source slice with its own name and every resolved reference rewritten to d_<hash12>, plus an index of dependency hashes. A stored cone is therefore a self-contained module.

@"<hash-prefix>"(args)

This calls that version live: the cone is materialized into a synthetic workspace, checked, and executed against its old meanings. Temporal calls require !io at the caller, because a past version's effects are unknown. The store is content-addressed and immutable — a hash is never rewritten, only added. rigid recall <prefix> [args] is the CLI form. The JS target refuses temporal calls at runtime.

15. Externs

extern fn name(a, b) !io = "host.path";

An extern declares a foreign function as a boundary node in the graph. It has arity (checked at every call form), an effect (propagated exactly), and a content identity over the declaration itself — so a changed host path is W0401 drift like any other meaning change.

The rigid VM refuses extern calls with a directive error; build --target js emits an arity-shaped wrapper over the dotted global path, which must be a valid identifier path (anything else is refused at build time, never emitted as executable text).

Declarations are trusted for arity and effect; values are not. Every extern return is checked at the boundary in emitted code. Integers, strings, and lists or records of the same pass through; booleans coerce to 1/0; non-integer numbers, null, undefined, and host objects are refused with an error naming the extern, so a float cannot enter rigid arithmetic silently. Integer-valued host numbers (for example Math.sqrt(4)) pass, because they are rigid integers.

16. Semantic diff

rigid diff compares two revisions as sets of identities and answers two questions separately, because they have different victims. Meaning: did any definition's hash move? Equal hash sets prove that none did, whatever the text shows. Surface: did any name or module move? Behaviour is intact, but callers outside the workspace refer to definitions by name. A pure refactor is meaning unchanged, surface changed — a pair that is not expressible in a language where a rename is an edit.

Every definition lands in exactly one bucket: renamed, moved, changed, added, or removed. Identity is matched before name, so a name re-used for something new reads as a rename plus an addition — the same event E0202 refuses to let happen silently.

17. Merge

rigid merge merges three revisions by definition rather than by line. Definitions are paired across revisions by identity first and name second: a surviving hash is the same definition whatever it is called, and a surviving name is the same definition whatever it now does.

Name and body then resolve independently. Two sides that renamed differently conflict on the name; two sides that edited differently conflict on the body; one of each does not conflict at all, and both changes survive. That last case is the one a line-based merge reports as a conflict, because it cannot see that the two edits are about different coordinates.

-o DIR writes the merged workspace. Bodies move as text, comments included — text is authoritative for content, so a merge relocates it rather than regenerating it from the graph. References inside a moved body are rewritten to the merged names, skipping string literals. Conflicts are refused rather than marked: nothing is written until they are resolved.

18. Diagnostics

Codes are stable and never renumbered. --json emits an array of {code, severity, file, span, message, data, fixes}, where fixes are typed and mechanically applicable (rename_reference, rebind, quarantine_call, add_dyn_targets). Machine consumers are first-class readers of compiler output.

CodeMeaning
E0101Syntax error.
E0201Unresolved name or import. Carries a rename hint when the pinned identity is found under another name.
E0202Resolution would change — the silent-rebind refusal.
E0203Ambiguous binding: an import colliding with a local definition, or a duplicate definition.
E0204Calling a value outside a dyn block; quarantine required.
E0205& or a dyn target is not a definition.
E0206Bare definition in value position; use &name.
E0207A capability is used or reached without being declared.
E0208Table misuse: direct call of a table, dispatch through a non-table, or a non-function target.
E0209Argument count does not match the target's arity.
E0210Assignment target is not a local; definitions are immutable.
E0211A memo definition's dependency cone reaches !io; memoization requires purity.
E0212A literal argument falls outside the parameter's inferred value domain.
E0213The selected compile target cannot represent this construct. Reserved; no target currently reports it.
E0214A rule in rigid.rules was violated — a forbidden reference, a missing test, or a missing attestation.
W0405An imported definition gained a capability it did not have when pinned. Error under --frozen.
E0301Retired in v1 (SCC hashing). The code stays reserved and is never reused.
W0401A pinned target's content drifted; repinned. An error under --frozen.
W0403A funref may reach a dyn site outside its declared set. Fix: add_dyn_targets.
W0404A pinned quarantine target's content changed. An error under --frozen.
I0402A new reference was pinned.
I0405Inferred quarantine set for an unquarantined call. Fix: quarantine_call.
I0406A same-module definition was renamed (identity match); typed rename fix.
I0407A dispatch table no test reaches, so nothing asserts which handler each key selects. Reported only where the workspace already has tests.
I0408A table key that is also the name of another target of the same table, but selects a different one — the shape a transposed pair leaves behind.

19. CLI

CommandDoes
check [--root D] [--frozen] [--json] [--require KIND]Parse, resolve, hash, reconcile the sidecars. --require KIND fails unless every definition carries a live attestation of that kind.
run PATH#NAME [ARGS…]Evaluate a definition. An argument is an integer, a float if it has a decimal point, or a string.
test [--no-cache]Run test_* definitions; results cached by content hash.
watchRe-check on every save and re-run only the tests the change could affect.
diff [--against D | --save]Grade a text diff by identity: what changed meaning versus what only changed name or file. --require-no-semantic-change for CI.
caps [--json]Which definitions reach the outside world, and through which extern.
attest PATH#NAME --kind K [--by WHO]Bind a claim — reviewed, audited, generated — to a definition's identity. Live while that hash is current, void the moment it is not. --list shows the ledger.
trust PATH#NAME [--json]Every live and stale claim about a definition, and which of its cone carries none.
dead [--entry PATH#NAME]…Definitions nothing can reach from a test, a main, or a named entry. A proof, not a search.
dup [--json]Definitions with the same identity — the same code written twice.
merge --base D --ours D --theirs D [-o D]Three-way merge over identities. A rename on one side and an edit on the other are different coordinates, so both survive.
why PATH#NAME [--json]What gets a new hash if this changes, which tests re-execute, and the reference path that reaches them.
hash (--all | PATH#NAME)Print content identities.
deps PATH#NAME [--json]Outgoing references.
rdeps PATH#NAME [--json]Blast radius.
fix [--apply]Apply typed rename_reference fixes and converge.
rebind PATH#NAMEExplicitly accept a name's changed meaning.
fmt PATH [--write]Canonical rendering. Comment-preserving and hash-neutral.
repl [--mod PATH]Evaluate expressions against the workspace.
recall PREFIX [ARGS…]Call a stored earlier version by hash prefix.
exactness [--json]Reference-exactness metric: singleton call sites over all call sites.
describeEmit the whole architecture as one hash-anchored JSON spec: every definition with identity, arity, effects, exact deps and rdeps, table topology, dyn sets, extern boundary, and test coverage.
build --target js [-o FILE]Compile the workspace to JavaScript.
lspLanguage server over stdio JSON-RPC: diagnostics, definition, references, hover-with-identity, quarantine code actions.

Every command accepts --root DIR to select the workspace. RIGID_CACHE_DIR relocates the test and memo caches so CI and a working copy can share one — the keys are content hashes, so a result computed anywhere is valid everywhere. RIGID_TIMING=1 reports where check spends its time.