Contents
- 1. Purpose
- 2. Lexical structure and grammar (provisional)
- 3. Types
- 4. Bindings and mutation
- 5. Functions and contracts
- 6. Effects
- 7. Claims
- 8. Capabilities
- 9. Paths
- 10. Errors
- 11. Modules and the interface
- 12. Verification
- 13. Diagnostics protocol
- 14. Constrained decoding interface
- 15. The review tool
- 16. Standard library (provisional)
- 17. Open questions
- 18. Worked examples
- 19. Targets
- 20. Testing
Onus — language specification, v0
Draft. Sections marked provisional are proposals to be argued with; everything else records a design decision from the September 2026 discussion.
1. Purpose
Onus is a programming language in which the primary author of function bodies is a language model, the primary reader is a human verifying claims about those bodies, and the only checker is the compiler.
Every design choice follows from three questions, in this order:
- Can the compiler reject it? If a class of error can be made a compile error, it is.
- Can the reviewer see it? Anything that affects what a function may do must be visible in its signature, not its body.
- Is it cheap for a model to write? Token count and verbosity are not costs. Ambiguity, hidden state and implicit behaviour are.
The burden of proof is on the code. A body is accepted when it demonstrably meets its contract; the reviewer’s job is to check that the contract says what was meant.
1.1 Principles
- Pure by default. A function with no declared effects can only compute.
- Explicit everything. No type inference, no implicit conversions, no default arguments, no ambient state, no reflection, no macros, no exceptions, no globals.
- Claims are checked or visibly assumed. Every obligation ends in exactly one of three states — proved, checked at runtime, or assumed — and the compiler reports which, per obligation.
- Authority flows down. Capabilities are unforgeable values constructed at the program root and attenuated on the way to the code that uses them. Nothing acquires authority it wasn’t handed.
- Local reasoning is sufficient. A function’s signature and contract must be enough to check every caller without reading the body. A module’s interface must be enough to review it.
- One compiler, no linter. Conventions that would otherwise be lint rules are expressed as claims or path declarations and checked by the same machinery as everything else. There is no second tool, no configurable rule set, and no warning level: a diagnostic is an error.
- Canonical form. There is exactly one way to format any program. Non-canonical source is a diagnostic with an automatic fix.
1.2 Non-goals
- Human typing ergonomics. Onus is verbose on purpose.
- Gradual adoption inside existing codebases. The trust model doesn’t survive an escape hatch that isn’t declared.
- Full dependent types or a general proof assistant. Onus targets a decidable verification fragment and pushes the rest to runtime checks, visibly.
- Concurrency, in v0. See §17.
2. Lexical structure and grammar (provisional)
The grammar is designed to be prefix-decidable so that a constrained decoder can compute the set of legal next tokens at every position with a single-token lookahead. Concretely: LL(1), no significant whitespace, no operator overloading, and mandatory delimiters.
Borrowing policy. The data model — discriminated unions with of, match ... with and when guards, { x with ... } record update — is taken from F#, because those designs are the best available and a model’s prior for them is exactly right. Everything else is deliberately not F#: explicit braces, mandatory annotations, named arguments, no currying, no pipelines. The intent is that a body does not read as any language the model already writes fluently, so that language’s habits (inference, partial application, Option idioms) are not triggered. Steal the semantics, not the silhouette.
- Identifiers:
[a-z][a-z0-9_]*for values and functions;[A-Z][A-Za-z0-9]*for types, unions, variants, claims, capabilities and modules. - Keywords are reserved and cannot be shadowed.
- Blocks are
{ ... }. Statements are newline-terminated; a statement cannot span lines except inside parentheses or brackets. - Comments:
--to end of line. Comments carry no semantics and are excluded from the canonical hash of a definition. changed: M1 — the canonical printer preserves comments, attached to the line-level construct they precede or follow; see docs/grammar-v0.md The canonical printer preserves them.
2.1 Operators
Onus has two precedence levels and no associativity surprises:
| Level | Operators |
|---|---|
| multiplicative | * / % |
| additive | + - ++ (text/list concatenation) |
Comparison operators (== != < <= > >=) are non-associative: a < b < c is a syntax error. and, or, not cannot be mixed without parentheses: a and b or c is a syntax error; (a and b) or c is required. There is no ternary; use if.
2.2 Canonical form
changed: M1 — the layout rules are made precise in docs/grammar-v0.md ("Canonical form"); see docs/CHANGES.md items 17–20The precise layout rules are in docs/grammar-v0.md. The compiler owns formatting. onus check reports non-canonical source as diagnostic E0001 with the canonical text as its fix. Indentation is two spaces; one declaration per top-level item; one blank line between items; named arguments always on the call line unless the call exceeds 100 columns, in which case one argument per line. Every definition has a canonical byte sequence and a content hash used for proof caching.
2.3 Grammar (provisional)
changed: M1 — the grammar as implemented is docs/grammar-v0.md; the productions below are patched where implementation showed them unworkable or inconsistent with the examples (docs/CHANGES.md items 1–16). Notable: continuation newlines before `->` `else` `{` `claims` `requires` `ensures` `invariant` `decreases` are not significant; `{ stmt }` on one line is accepted and canonicalised; soft keywords.The grammar as implemented, with the canonical form, is docs/grammar-v0.md; the EBNF below is patched to agree with it. EBNF. NL is a newline token; the lexer emits one per line end outside brackets. Every production is intended to be LL(1); where it is not, that is a bug in this section, not a feature of the language.
module = [ "test" ] "module" QNAME NL { "import" QNAME NL } { item } ; (* changed: M1, test modules §8.4 *)
item = fn_decl | type_alias | record_decl | union_decl | interface_decl
| impl_decl | claim_decl | capability_decl | path_decl | policy_decl
| example_decl | property_decl | const_decl ;
visibility = [ "pub" ] [ "sealed" ] ;
fn_decl = visibility [ "const" ] [ "intrinsic" ] "fn" NAME [ tparams ] "(" [ params ] ")" "->" type [ "may" effects ] (* changed: M9, docs/CHANGES.md item 82 — `may` replaces `!` before an effect list, everywhere the grammar had it *)
[ "claims" claim_list ] { contract } ( block | NL ) ; (* changed: M1 newlines; M2 intrinsic §3.12: no body *)
tparams = "[" tparam { "," tparam } "]" ;
tparam = TNAME [ ":" TNAME ] (* type parameter, optional bound *)
| "const" NAME ":" type (* compile-time value parameter *)
| NAME ; (* effect parameter *)
params = param { "," param } ;
param = NAME ":" [ "inout" ] type ; (* changed: M1, matches §4.1 and the call site *)
effects = effect { "," effect } ;
effect = QNAME | NAME ; (* primitive, claim, or effect parameter *)
contract = "requires" [ "proved" ] expr
| "ensures" [ "proved" ] expr ;
type = QTNAME [ "[" targ { "," targ } "]" ] [ "where" expr ]
| "fn" "(" [ params ] ")" "->" type [ "may" effects ] ; (* changed: M2, parameters are named; §3.7 *)
targ = [ NAME ":" ] ( type | expr ) ; (* expr must be constant, §3.8; changed: M1, labelled per §8.2 *)
type_alias = visibility ( "type" TNAME "=" type | "intrinsic" "type" TNAME [ tparams ] ) NL ; (* changed: M2, §3.12 *)
const_decl = visibility "const" NAME ":" type "=" expr NL ;
record_decl = visibility "record" TNAME [ tparams ] "{" NL { field NL } "}" NL ;
field = NAME ":" type ;
union_decl = visibility "union" TNAME [ tparams ] "=" NL { "|" variant NL } ;
variant = TNAME [ "of" field { "," field } ] ;
interface_decl = visibility "interface" TNAME "[" TNAME "]" "{" NL { iface_item NL } "}" NL ;
iface_item = "fn" NAME "(" [ params ] ")" "->" type [ "may" effects ] NL { contract NL }
| "law" NAME "(" [ params ] ")" block ;
impl_decl = "impl" TNAME "[" type "]" "{" NL { fn_decl } "}" NL ;
claim_decl = visibility "claim" TNAME ( ":=" claim_pred | STRING ) NL ; (* changed: M1, claim_pred is the §6.3 effect-predicate language *)
capability_decl = visibility "capability" TNAME [ tparams ] NL { "grants" effect [ "when" expr ] NL } ;
path_decl = "path" NAME NL "entry" NAME NL { path_clause NL } ;
path_clause = "effects" "<=" "{" [ effects ] "}"
| "forbid" "{" effects "}"
| "require" "{" claim_list "}"
| "policy" NAME [ "except" "{" QNAME { "," QNAME } "}" ] ;
policy_decl = "policy" NAME NL "forbid" "assume" "outside" "{" scope { "," scope } "}" NL ;
scope = "self" | QNAME [ "." "*" ] ; (* changed: M1 *)
example_decl = "example" NAME block ;
property_decl = "property" NAME "(" [ params ] ")" block ;
block = "{" ( NL { stmt NL } | [ stmt ] ) "}" ; (* changed: M1, one-line form canonicalises to multi-line *)
| "{" "..." "}" ; (* changed: M7, docs/CHANGES.md item 72 — an elided body, valid only as a fn body in an interface rendering (§11.1); E0115 elsewhere *)
stmt = "let" NAME ":" type "=" expr
| "var" NAME ":" type "=" expr
| NAME "=" expr
| "return" expr
| "if" expr block [ "else" ( block | if_stmt ) ]
| "match" expr "with" NL { "|" pattern [ "when" expr ] "->" ( stmt | block ) NL }
| "loop" "while" expr { loop_clause } block (* changed: M1 *)
| "for" NAME ":" type "in" domain block (* changed: M1 *)
| "assume" TNAME STRING [ NL verify_block ] (* changed: 2026-09-03, docs/CHANGE-LOG-02.md; `verify` is a reserved word *)
| expr ; (* call for effect *)
verify_block = "verify" "(" [ params ] ")" [ "may" effects ] block ; (* changed: 2026-09-03, docs/CHANGE-LOG-02.md — §20.2 *)
loop_clause = "invariant" expr | "decreases" expr ;
domain = expr [ "..<" expr ] ; (* changed: M1, ranges §5.1 §5.3 *)
expr = or_expr [ "implies" or_expr ] ; (* changed: M1, non-associative; §3.6 *)
or_expr = and_expr [ "or" and_expr { "or" and_expr } ] ;
and_expr = not_expr [ "and" not_expr { "and" not_expr } ] ;
not_expr = [ "not" ] cmp_expr ;
cmp_expr = add_expr [ CMP_OP add_expr | "is" pattern ] ; (* non-associative; changed: M1, `is` §3.8.1 *)
add_expr = mul_expr { ( "+" | "-" | "++" ) mul_expr } ;
mul_expr = unary { ( "*" | "/" | "%" ) unary } ;
unary = [ "-" ] postfix ;
postfix = primary { "." NAME | [ "[" targ { "," targ } "]" ] call_args } ; (* changed: M1, explicit args §18.2 *)
primary = LITERAL | NAME | "it" | "result"
| QTNAME [ call_args ] [ "{" [ field_init { "," field_init } ] "}" ] (* record ctor / variant ctor *)
| "{" expr "with" field_init { "," field_init } "}" (* record update *)
| "(" expr ")" | "[" [ expr { "," expr } ] "]"
| "try" expr [ "else" ( NAME | "_" ) ":" expr ] (* changed: 2026-09-04, docs/CHANGES.md item 87 — `_` discards the error *)
| "recover" block
| "old" "(" NAME ")"
| ( "forall" | "exists" ) NAME ":" type [ "in" domain ] [ "where" expr ] ":" expr (* changed: M1; the binder type has no where of its own *)
| "fn" "(" [ params ] ")" "->" type [ "may" effects ] block (* closure *)
| "fake" QTNAME "{" [ field_init { "," field_init } ] "}" ; (* changed: M1, §8.4; syntax error outside a test module *)
call_args = "(" [ NAME ":" [ "inout" ] expr { "," NAME ":" [ "inout" ] expr } ] ")" ; (* changed: M1, §4.1 *)
field_init = NAME ":" expr ;
pattern = "_" | NAME | LITERAL
| QTNAME [ "(" pat_field { "," pat_field } ")" ] ;
pat_field = NAME | "_" | ".." ; (* fields in declaration order; NAME binds that field *)Notes: or_expr and and_expr cannot nest without parentheses, so mixed and/or is rejected by the parser, not a later pass. In expression position { begins a record update and [ a list literal; blocks only occur in statement position, so there is no ambiguity. A match arm body is a single statement or a block. if ... else if chains are sugar for nested blocks and are canonicalised as such. A bare expr statement must be a call; anything else is E0002 expression statement has no effect. Durations (50ms, 2s) are LITERALs of type Duration.
3. Types
3.1 Primitives
| Type | Notes |
|---|---|
Int | 64-bit signed. Overflow is a contract violation, not wraparound. Refinements narrow it. |
Float | IEEE 754 double. NaN and infinities are values that must be excluded by refinement or handled by match on Float.classify. |
Bool | |
Text | UTF-8, immutable, no character indexing. Grapheme and byte views via the standard library. Literals are single-line; a newline is written \n. changed: M1, one canonical spelling per value |
Unit | The single value Unit. |
Byte | Int where 0 <= it and it <= 255; a refinement alias, not a distinct representation. |
Bytes | Immutable byte sequence. Indexed by Int where 0 <= it and it < len. |
Duration | Non-negative nanoseconds. Literals 50ms, 2s, 1500us. Arithmetic is Int-like and verified as such. |
Fixed-width and unsigned integers are refinement aliases over Int (type UInt16 = Int where 0 <= it and it <= 65_535). Wire layout is a Bytes codec concern, not a type concern; values never carry a representation.
There are no implicit conversions. Float.of(x: Int) and Int.floor(x: Float) -> Result[Int, RangeError] are library functions.
3.2 Refinement types
type Iter = Int where 0 <= it and it <= 10_000
type Coord = Int where 0 <= it
type Ratio = Float where 0.0 <= it and it <= 1.0it names the value being refined. A refined type is a subtype of its base; the base is not a subtype of the refinement. Flowing a base value into a refined position generates an obligation.
Refinements may also be written inline on parameters and fields: limit: Iter where it > 0.
Units of measure (F#’s float<m>) are the obvious next refinement idiom — a phantom index on Int/Float checked by the type system rather than the verifier. Not in v0; recorded in §17.
Verification status by base type:
Intrefinements in linear arithmetic (comparison,+,-, multiplication by a constant) are proved.Intrefinements involving*of two variables,/or%are proved where the solver can and checked at runtime otherwise.Floatrefinements are checked at runtime unless the function is annotatedproves float, which admits real-arithmetic reasoning and is expected to time out often.
3.2.1 Flow-sensitive refinement
The verifier’s knowledge of a value is path-sensitive. Within a branch, the branch condition and everything implied by prior conditions on the path are available when discharging obligations:
fn clamp(x: Int, lo: Int, hi: Int where it >= lo) -> Int where lo <= it and it <= hi {
if x < lo { return lo } -- obligation: lo <= lo <= hi. proved.
if x > hi { return hi } -- proved.
return x -- here: not (x < lo) and not (x > hi). proved.
}Rules:
- A condition of the form
expronif, the negation onelse, amatcharm’s variant and guard, and a loop’swhilecondition inside its body are all added to the path knowledge for that scope. - A
varloses path knowledge at every assignment; only its declared type remains. - Knowledge about
xdoes not survive a call that passesxasinout. - The compiler does not change a binding’s static type on a branch.
xis stillInt; the refinement obligationx >= 0is simply discharged from path knowledge when needed. This keeps types stable and readable in diagnostics. - Refinements are never inferred onto declarations. If a function’s interface should promise
result >= 0, that is anensuresor a refined return type, written explicitly.
3.3 Records
record Viewport {
x_min: Float
x_max: Float where it > x_min
y_min: Float
y_max: Float where it > y_min
}Records are structural value types. Field refinements may reference earlier fields (dependent records); the constraints are obligations at construction and at every with.
Update is by with, producing a new value: { view with x_max: 2.0 }.
3.4 Unions
union Pixel =
| Inside
| Escaped of at: Iter where it > 0
| Skipped of reason: TextConstruction uses the variant name with named fields: Escaped(at: 12), Inside. Variant fields are named, never positional, at construction; patterns list them in declaration order (§3.9).
Unions are nominal and closed. match must be exhaustive and has no default arm; adding a variant breaks every match that does not handle it. An arm no value can reach is likewise an error. Variant payloads may carry refinements. Unions have no numeric backing and no ordering unless an Ord implementation is provided.
Variant names are in scope bare within the declaring module, and from importing modules either bare (when exactly one imported or prelude union declares that name) or qualified by the module alias (sql.Refinement). Two unions in one module may not share a variant name. changed: M2, docs/CHANGES.md item 30
Option[T] and Result[T, E] are ordinary library unions.
3.5 Indexed types
Types may be parameterised by compile-time Int values as well as by types:
Grid[T, width: Int where it > 0, height: Int where it > 0]An index must be a constant, a parameter refined at least as strongly, or an expression the verifier can evaluate. Index arithmetic follows the same verification rules as Int refinements.
3.6 Generics and interfaces
An interface declares signatures, contracts on those signatures, and law blocks: properties that must hold for every implementation. A law is quantified over the implementing type and becomes an obligation on every impl, so generic code that relies on it can be verified once against the interface rather than per type.
interface Codec[T] {
fn encode(value: T) -> Text may alloc
fn decode(text: Text) -> Result[T, CodecError] may alloc
law round_trip(value: T) {
decode(text: encode(value: value)) == Ok(value)
}
}
impl Codec[MonthlyTotal] {
fn encode(value: MonthlyTotal) -> Text may alloc {
return value.month ++ "," ++ Int.to_text(x: value.total_pence)
}
fn decode(text: Text) -> Result[MonthlyTotal, CodecError] may alloc {
...
}
}The law says exactly what it means: whatever is written out is read back. Text is opaque to the verifier, so round_trip for this impl is discharged by running it under generated MonthlyTotal values and reported in the interface as checked, never proved.
Laws over integers are usually provable:
interface Ord[T] {
fn compare(a: T, b: T) -> Ordering
ensures compare(a: a, b: a) == Equal
ensures compare(a: a, b: b) == Less implies compare(a: b, b: a) == Greater
law transitive(a: T, b: T, c: T) {
(compare(a: a, b: b) == Less and compare(a: b, b: c) == Less) implies compare(a: a, b: c) == Less
}
}
impl Ord[Int] {
fn compare(a: Int, b: Int) -> Ordering {
if a < b { return Less }
if a > b { return Greater }
return Equal
}
}
-- both ensures and transitive: provedRules:
- Implementations are explicit (
impl Ord[Int] { ... }); there is no structural or automatic conformance. - Generic code calls an interface function through the interface:
Ord.compare(a: x, b: y). The implementing type is taken from the arguments and must have animpl, or be a type parameter bounded by the interface. changed: M2, docs/CHANGES.md item 35 - Every contract and law on the interface is an obligation on every
impl, with the usual proved/checked status. - An
implmay not declare effects beyond those in the interface signature. An interface that wants to admit allocating or effectful implementations declares the effect (asCodecdoes withalloc) or is effect-polymorphic (fn f(x: T) -> U may e). - There is no overloading and no ad-hoc polymorphism outside interfaces.
Type parameters are declared with their constraints: fn sort[T: Ord](xs: List[T]) -> List[T] may alloc.
3.7 Function values and closures
A function value has type fn(a: A, b: B) -> R may e. Parameters of a function type are named — the names are the labels a caller uses, since every call passes arguments by name (§5) — and may carry refinements; the effect set is part of the type. A closure assigned to a function type may name its own parameters differently; assignability compares positions, types and effects. changed: M2, function types carry parameter names; without them a call through a function value could not be written
let step: fn(n: Int where it >= 0) -> Int where it >= 0 = fn(m: Int where it >= 0) -> Int where it >= 0 {
return m + 1
}
step(n: 3)Rules:
- A closure captures
letbindings and parameters by value. It cannot capture avar, aninoutparameter, or a capability. - A closure’s effect set is the union of the effects of everything it calls. Passing a closure to an effect-polymorphic parameter
fn(T) -> U may einstantiatese. - Function values are values: they may be stored, returned and passed. They may not be compared for equality.
- Provenance. For path checking, a call through a function value is resolved to the set of closures and named functions that could reach it, computed by a whole-program flow analysis over
letbindings, record fields, and list literals. Where that set is finite and known, the path check proceeds against each member. Where it is not — a function value read from aMap, decoded from input, or built from a value the analysis cannot follow — the call is unresolvable and any path that reaches it fails withE0410. Code off a path may call unresolvable function values freely; effect checking is still exact there, because the type of the value bounds its effects even when its identity is unknown.
3.8 Compile-time constants
A const is an expression the compiler evaluates during checking:
pub const max_iter: Iter = 10_000Constant expressions are: literals; const names; arithmetic, comparison and boolean operators over constants; record and union construction from constants; and calls to functions marked const fn, which must be pure, total, and have every obligation proved.
Constant parameters ([const width: Int where it > 0]) let a function or type demand a value known at check time. Type indices (Grid[T, 800, 600]) are constant parameters.
3.8.1 Library-defined check-time validation
There are no macros, syntax extensions or compiler plugins. Anything a library wants checked at compile time, it checks with a const fn that the compiler evaluates during checking. The library brings its own parser; the compiler brings the evaluator.
-- in std.sql, ordinary Onus, verified like everything else
pub const fn parse_select(text: Text) -> Result[SelectAst, ConstError] may alloc
pub const fn columns_match(text: Text, record: TypeInfo) -> Result[Unit, ConstError] may alloc
pub intrinsic fn select[const text: Text, T](params: List[Param], row: TypeInfo) -> Select[T]
requires proved parse_select(text: text) is Ok
requires proved columns_match(text: text, record: row) is OkRules:
- A
const fnmust be pure apart fromalloc, andtotal, with every obligation proved. The check-time evaluator therefore always terminates and never panics. changed: M4, docs/CHANGES.md items 52–55: allocation is admitted; until obligations are proved, a failing contract during evaluation is E0701 and a budget overrun is E0501 - Evaluation runs under the standard per-obligation budget (§12.3). A library cannot make checking slow without
E0501saying whichconst fndid it. ConstError(std.check.ConstError { offset, message }) carries a graphemeoffsetinto the constant being checked. When arequires provedclause of a call with constant arguments evaluates to false and aConstErrorwas produced, the compiler emitsE0700 library check failedwith the location mapped into the literal passed for the callee’s firstconstText parameter — a malformed SQL string is reported at the character in the string, not at the call. changed: M4, docs/CHANGES.md items 53–54- A
const fnmay compute over types:columns_matchreceives the record typeTas aTypeInfovalue (fields, names, refinements) and compares it to the parser’s projected columns. Field/column mismatch is a compile error. - A
const fnmay return aSpec— a value of the contract language’s own AST type. Arequiresorensuresclause may reference it withspec(...), and the verifier consumes it as a predicate. This is how awhereclause in a query becomes a postcondition on the rows (§18.3) withoutstd.sqlhaving any standing the user’s own library could not have.
The design intent: the compiler has no knowledge of SQL, regular expressions, date formats, URLs, or anything else. A library that wants its literals checked writes the checker in Onus, ships it, and gets the same diagnostics and the same verification of its checker as user code.
3.9 Pattern matching
match p with
| Inside -> return 0
| Escaped(at) when at < 10 -> return 16
| Escaped(at) -> return 255 * at / limit
| Skipped(..) -> return 128- Arms are tried in order. A guard (
when expr) makes an arm conditional; the compiler tracks guard coverage, so the twoEscapedarms above are exhaustive together and would not be with only the first. - A pattern lists the variant’s fields in declaration order. A field name binds that field;
_skips one;..skips the rest; a bare variant name matches any payload, likeVariant(..). Writing a name that is not the field at that position isE0310 pattern field mismatch— patterns cannot rename. changed: M3, docs/CHANGES.md item 51 - Nested patterns on payload fields are not supported in v0; match on the field in a nested
match. (This keeps exhaustiveness checking trivially decidable and the grammar LL(1). Revisit if it proves painful.) - Bound fields carry their declared refinements into the arm as path knowledge.
matchonBool,IntandTextliterals is permitted and requires a_arm.
3.10 Visibility and sealed types
- Items are private to their module unless
pub. pub recordandpub unionexpose the type, its fields or variants for reading and matching, and construction.pub sealed record/pub sealed unionexpose the type and reading, but construction andwithare restricted to the defining module. This is how a type becomes evidence:auth.AuthedCustomerispub sealed, so the only way to obtain one is through a function inauththat chose to return it.sealedis the whole mechanism for typestate. There is no separate feature.
3.11 Streams
Stream[T] may e is a lazy sequence whose elements are produced on demand with effect e. for iterates ranges, List[T] and Stream[T]; iterating a stream adds its effect to the enclosing function.
fn total_pence(rows: Stream[Order] may sql.read) -> Result[Int where it >= 0, sql.Error] may sql.read {
var sum: Int where it >= 0 = 0
for row: Order in rows {
sum = sum + row.amount_pence -- overflow obligation: checked
}
return Ok(sum)
}Streams are single-pass and cannot be stored in records or captured. Library sources: sql.stream, io.lines, io.chunks, List.stream. Termination of a for over a stream is the stream’s responsibility: a stream is declared finite or the consuming function declares diverge.
3.12 Intrinsics
changed: M2, the bottom of the standard library needs a declared, closed way out to the runtime; see docs/CHANGES.md item 25The standard library is Onus, but its lowest operations — grapheme counting, float conversion, typed-array grids, file and socket access — have no Onus body. They are declared as intrinsics:
pub intrinsic type Grid[T, const width: Int where it > 0, const height: Int where it > 0]
pub intrinsic fn len(t: Text) -> Int where it >= 0
pub intrinsic fn set[T, const w: Int, const h: Int](g: inout Grid[T, w, h], x: Int where 0 <= it and it < w, y: Int where 0 <= it and it < h, v: T) -> Unit may mutate
ensures get(g: g, x: x, y: y) == vRules:
intrinsicis legal only in modules namedstd.…, and only the compiler’s own standard library may declare such modules. There is no other way to reach code that is not Onus; this is the declared, enumerated escape hatch that §1.2 requires.- An intrinsic function has a signature, effects and contracts but no body. It is bound by its qualified name to an export of the runtime package; a missing or mismatched export is a build failure.
- An intrinsic type is opaque: it has no fields or variants, and only the functions of its module operate on it.
- An intrinsic’s
ensuresclauses and effects cannot be proved. They are recorded in the ledger as assumed obligations whose justification names the runtime module, so every path that reaches an intrinsic shows that trust (§12.2). Itsrequiresclauses are ordinary obligations at call sites. - A
const intrinsic fnmay be evaluated at check time (§3.8); the evaluator calls the same runtime implementation. Effectful intrinsics are never evaluated at check time. - Where an operation can be written in Onus over a smaller intrinsic, it should be, so that the compiler proves its contract instead of assuming it. The set of intrinsics is enumerated by a fixture and changes to it are reviewed.
4. Bindings and mutation
let x: T = e— immutable binding. Required annotation.var x: T = e— mutable local. Assignmentx = ere-checksT’s refinement.- A local may not reuse the name of another local or parameter. changed: M2, docs/CHANGES.md item 32
- A bare expression statement must be a call whose result is
Unit; a discarded value is an error. changed: M2, docs/CHANGES.md item 39 - Closures capture by value and cannot capture a
var. - There is no reference type, no address-of, and no aliasing of mutable state anywhere in the language.
4.1 inout parameters
A function that mutates an argument declares it, and the call site marks it:
fn set(grid: inout Grid[T, w, h], x: Int where it < w, y: Int where it < h, value: T)
ensures grid.get(x: x, y: y) == value
ensures forall px: Int, py: Int where (px, py) != (x, y): grid.get(x: px, y: py) == old(grid).get(x: px, y: py)
set(grid: inout grid, x: 3, y: 4, value: 0)Rules: an inout parameter may not be stored, returned, captured, or passed to another inout position while still borrowed in the current frame. The same var may not be passed inout twice in one call. Because nothing aliases, the compiler may mutate in place whenever the previous value is dead; semantics are always value semantics.
old(x) in an ensures clause refers to the value of an inout parameter at entry.
5. Functions and contracts
fn escape_count(cx: Float, cy: Float, limit: Iter) -> Iter
requires limit > 0
ensures result <= limit
{
...
}requires— precondition. Obligation at every call site.ensures— postcondition overresultandold(...). Obligation at everyreturn.- Arguments are passed by name at every call. Positional calls are a syntax error.
- Functions are values with type
fn(A) -> R may effects.
5.1 Loops and termination
loop while cond
invariant i <= limit
decreases limit - i
{ ... }
for py: Int in 0 ..< height { ... }for over a range is total by construction. loop while requires a decreases clause (a non-negative integer expression that strictly decreases each iteration) unless the enclosing function declares the diverge effect. Recursion follows the same rule: a recursive function must declare decreases on a parameter or declare diverge.
Loop invariant clauses are obligations at entry and at the end of every iteration, and are assumptions after exit.
Recursion: a recursive function declares decreases on an expression over its parameters, as a contract clause after requires/ensures (decreases n), checked at every recursive call site. changed: M3, docs/CHANGES.md item 43 changed: 2026-09-05, docs/CHANGES.md items 120–121 — the call-site and entry obligations are generated, discharged and otherwise checked at runtime; "same expression up to renaming" is parameters numbered by position A measure may also be a value of a record, union or list type, meaning the structural order: at each recursive call the argument must be a proper part of the value at entry, a field or a list element reached by pattern matching, field access or List.get. The compiler proves this from how the argument was obtained or rejects the function (E0344 structural measure not shown to decrease); a structural measure is never checked at runtime. changed: 2026-09-05, docs/CHANGES.md item 133 — structural measures, needed by every walk over a syntax tree in the compiler in Onus A call may also pass the measure on unchanged to another function of the cycle, as to a helper that takes the same node and recurses on its parts; this is accepted when the calls that pass the measure unchanged form no cycle of their own, so that every cycle takes a proper part somewhere, and is E0344 otherwise. changed: 2026-09-05, docs/CHANGES.md item 139 — calls passing a structural measure on unchanged, which the type checker in Onus needs Mutual recursion requires the same expression (up to renaming) on every function in the cycle; the compiler computes the cycle and reports E0320 recursive cycle without shared measure otherwise.
5.3 Quantifiers
Contracts, invariants, laws and properties may use forall and exists:
ensures forall o: Order in result: o.customer == who.id
ensures forall px: Int, py: Int where (px, py) != (x, y): grid.get(x: px, y: py) == old(grid).get(x: px, y: py)
invariant exists i: Int in 0 ..< n: xs.get(i: i) == targetThe verifier models List[T] and Bytes as sequences with len and get, Grid as a two-dimensional sequence, Map[K, V] as a partial function, and Text as opaque except for len and equality. Quantification is over a stated domain — a range, a list, or a finite type — so every quantified obligation is bounded and stays in the decidable fragment. A domain of type Result[List[T], E] or Option[List[T]] ranges over the contained list and the formula is vacuously true for Err and None, which is how ensures forall o: Order in result: … reads a fallible result. changed: M3, docs/CHANGES.md item 51 Quantification over all Int without a range is a syntax error in v0. Nested quantifiers are permitted to depth two.
5.2 Examples and properties
example escape_count {
escape_count(cx: 0.0, cy: 0.0, limit: 100) == 100
escape_count(cx: 2.0, cy: 2.0, limit: 100) == 1
}
property escape_bounded(cx: Float, cy: Float, limit: Iter where it > 0) {
escape_count(cx: cx, cy: cy, limit: limit) <= limit
}example blocks are concrete assertions evaluated at check time when every statement is evaluable (pure functions and constant values) — a false assertion is E0702 — and otherwise by the generated tests; both are shown in the module interface. changed: M4, docs/CHANGES.md item 56 property blocks are universally quantified; the compiler attempts to prove them, and where it cannot, runs them under generated inputs (with the refinements as generators) and reports the result as checked, never proved. Both are part of a function’s interface and are read by the reviewer as evidence of intent.
6. Effects
A function’s effects are declared after may. Absence means pure. changed: M9, docs/CHANGES.md item 82 — `may` replaces `!`, which reads as negation
fn main(args: List[Text]) -> Result[Unit, IoError] may io.file, alloc6.1 Primitive effects
The compiler understands these directly:
| Effect | Meaning |
|---|---|
alloc | May allocate on the heap. Absence is proved by the absence of allocating operations: list literals, ++, closure creation, and calls to allocating functions. Records and variants are values and do not allocate. changed: M3, item 46 |
mutate | May mutate one of its own inout parameters, by assignment or by passing it on inout. A callee’s mutate does not propagate through a caller’s local var. changed: M3, item 45 |
panic | May halt on a runtime contract violation. A function without panic must have every obligation proved, except integer overflow obligations, which in v0 are runtime checks against the implementation’s ±2^53 range and are reported as such in the ledger. changed: M6, docs/CHANGES.md item 67 |
diverge | May fail to terminate. |
nondet | Result depends on something other than its arguments (clock, randomness, scheduling). |
io.file, io.net, io.env, io.clock, io.rand | Access to the corresponding resource, via a capability. |
The set is closed. Resource effects such as sql.read are declared by a capability’s grants clause in the module whose name ends in sql and are spelled sql.read everywhere (§8); any other effect name is an error. Library-defined properties beyond these are claims (§7). changed: M3, item 44
6.2 Composition
A callee’s effect set must be a subset of the caller’s. This is the only rule, and it is checked structurally.
Higher-order functions are effect-polymorphic; passing a function value to a parameter of type fn(x: T) -> U may e binds e to the value’s effects beyond those the parameter lists, and a function value never flows into a function-typed position declaring fewer effects than it has: changed: M3, item 47
fn map[T, U, e](xs: List[T], f: fn(x: T) -> U may e) -> List[U] may e, alloc6.3 Derived effect predicates
Named predicates over the primitive set are definable anywhere and are fully checked: changed: M1, claims are type names per §2 and the grammar
claim Pure := effects == {}
claim Total := not diverge and not panic
claim RealtimeSafe := Total and not alloc7. Claims
A claim is a named property that propagates over the call graph. Claims come in three tiers, distinguished by how their truth is established.
| Tier | Defined by | Checked by | Introduced at |
|---|---|---|---|
| primitive | the compiler (§6.1) | the compiler | leaf operations |
| derived | a predicate over primitives and other claims | the compiler, by expansion | wherever the predicate holds |
| asserted | a name and a description | propagation only | an assume leaf |
7.1 Asserted claims
claim Idempotent
"Calling twice with the same arguments has the same observable effect as calling once."
fn charge(pay: Payments, req: ChargeRequest) -> Result[Receipt, ChargeError] may io.net
claims Idempotent
{
assume Idempotent "Vendor API deduplicates on req.key for 24h; see contract §4.2"
...
}An asserted claim is sound relative to its assume leaves and nowhere else. The compiler propagates it exactly as it propagates effects — a function may claim Idempotent only if every function it calls that participates in the relevant effect also claims it — but records every assume in the ledger with its justification text and location. changed: M8, docs/CHANGES.md item 76 — v0 participation: a callee participates when it has an observable effect (`io.file`, `io.net`, or a resource effect); `alloc`, `mutate`, `panic`, `diverge`, `nondet`, `io.env`, `io.clock` and `io.rand` are quiet. An `assume` in a function covers everything it calls. A function whose body assumes the claim need not propagate it (`E0204`); `assume` is only for asserted claims the function declares (`E0205`, `E0206`); a declared derived claim whose predicate fails is `E0203`.
Claims are namespaced by module. Two modules may define claims with the same short name; they are distinct.
7.2 Policy
A path (§9) or a module may constrain assumptions:
policy no_third_party_assumes
forbid assume outside { self, std.* }This is how a critical path refuses to depend on a library’s word.
8. Capabilities
A capability is an unforgeable value that both grants access to a resource and carries the claims the resource has been configured to honour. changed: M1, example rewritten per the tparams grammar; no set-membership expression in v0
capability Db[const mode: DbMode]
grants sql.read when mode == ReadOnly or mode == ReadWrite
grants sql.write when mode == ReadWrite- Capability types have no public constructor. They are created only by root-level functions in the standard library or by attenuation.
- An effect that names a resource (
sql.read,io.file, …) is only satisfiable if a capability granting it is in scope as a parameter. - Capabilities are ordinary parameters. They may not be stored in records or captured by closures; they are threaded explicitly. This is deliberate: the flow of authority is visible in every signature it passes through. changed: M8, docs/CHANGES.md item 77 — a record field of capability type is `E0601`; closure capture is `E0330`; a union payload (`Result[Db[..], Error]`) is allowed, since that is how constructors return them
8.1 Construction and the ledger
fn main(args: List[Text]) -> Result[Unit, AppError] may io.env, sql.read, sql.write, alloc {
let env: Env = io.env.capability()
let cfg: Config = try config.load(env: env)
let reporting: Db[ReadOnly] = try sql.connect(net: net, dsn: cfg.reporting_dsn, mode: ReadOnly)
...
}sql.connect with mode: ReadOnly opens the connection with a read-only session (SET default_transaction_read_only = on for Postgres; equivalent per driver) and additionally verifies the role’s privileges at connect time. changed: 2026-09-05, docs/CHANGES.md item 100 — the v0 check verifies the setting took and refuses a superuser role The remaining assumption — that the role named in cfg.reporting_dsn was not granted write privileges after the check — is recorded in the ledger as an assumed leaf attached to the capability’s construction site, so the reviewer’s view of any path using reporting says where the guarantee bottoms out.
8.2 Attenuation
let ro: Db[ReadOnly] = sql.narrow(db: rw, to: ReadOnly)
let orders_ro: Db[ReadOnly, schema: "orders"] = sql.restrict(db: ro, schema: "orders")
let bounded: Db[ReadOnly, deadline: 50ms] = sql.deadline(db: ro, ms: 50)Attenuation is total and pure apart from alloc; it can only remove authority. A function requiring Db[ReadOnly] accepts any capability whose mode is ReadOnly or stronger-restricted.
8.3 The root
Root capabilities are constructed by the runtime, not by code. main is the only function that may declare root capability parameters, and it receives exactly the ones it names:
pub fn main(args: List[Text], env: io.Env, files: io.Files, net: io.Net) -> Result[Unit, AppError] may io.env, io.file, io.net, allocA program whose main does not name io.Net cannot, anywhere, open a socket — there is no other source of a Net capability. The runtime closes every root capability’s underlying resources when main returns. changed: M8, docs/CHANGES.md item 77 — a `main` parameter of a non-root capability type is `E0602`, since nothing could supply it Library functions that produce capabilities (sql.connect) take a root capability as an argument (net: io.Net) so the derivation is visible; the sql example in §18.2 shows this.
8.4 Test doubles
Capabilities are unforgeable, which means ordinary code cannot construct a fake Db[ReadOnly] for a test. A module declared test module may:
- construct any capability type via
fakewith a caller-supplied behaviour table; - call
sealedconstructors of any module it imports; - contain
exampleandpropertyblocks against non-public functions.
test module checkout_tests
import app.checkout
example handle_checkout_empty_basket {
let db: sql.Db[ReadWrite, schema: "orders"] = fake sql.Db { query: fn(...) { ... }, execute: fn(...) { ... } }
...
}The compiler refuses to link a test module into a non-test build (E0600), and fake is a syntax error outside one. A fake capability grants the same effects as the real one, so effect checking of the code under test is unchanged; only the resource behind it differs.
9. Paths
A path declares the guarantees a critical section of the program must satisfy, and the compiler checks them against every function reachable from the entry point.
path checkout
entry handle_checkout
effects <= { sql.read, sql.write, io.net, io.clock, alloc }
require { Total, Idempotent }
policy no_third_party_assumesChecking: the compiler computes the reachable set from entry. Every function in it must have effects within the declared bound and must carry every required claim. Calls through function values are resolved where the value’s provenance is known; where it is not, the path check fails closed with diagnostic E0410 unresolvable call on path checkout. changed: M8, docs/CHANGES.md item 78 — v0 tracks no provenance: every call through a function value, and every interface call dispatched on a type parameter, is `E0410`; interface calls on concrete receivers resolve to the implementation. A function outside the bound is `E0412`, a forbidden effect `E0413`, a missing required claim `E0414`, an `assume` the policy does not permit `E0415`. A required asserted claim is checked on reachable functions with observable effects that are not under an `assume` of it; a required derived claim on every reachable function.
Clauses (grammar in §2.3):
entry NAME— the root of the reachable set. Exactly one.effects <= { ... }— an upper bound on the union of effects of every reachable function.forbid { ... }— effects that must not appear anywhere reachable. Redundant witheffects <=but reads better for the reviewer, and the two must be consistent (E0411).require { ... }— claims every reachable function that has any effect must carry. Pure functions are exempt from claims about effects.policy NAME [except { qualified names }]— applies a policy.exceptlists specific functions whoseassumeleaves are permitted despite the policy; each is reported individually.
9.1 The path report
onus path <name> --json emits:
{
"path": "checkout",
"entry": "app.checkout.handle_checkout",
"reachable": ["app.checkout.handle_checkout", "app.auth.require", "..."],
"effects": { "bound": ["sql.read", "sql.write", "io.net", "io.clock", "alloc"], "actual": ["..."] },
"claims": { "required": ["Idempotent"], "satisfied": true },
"assumes": [
{ "claim": "Idempotent", "at": "vendor.payments.charge", "justification": "Vendor API deduplicates on req.key for 24h; see contract §4.2", "permitted_by": "except" }
],
"obligations": { "proved": 31, "checked": 2, "assumed": 1,
"checked_at": ["std.sql.query#row_refinement", "..."] },
"unresolvable_calls": [],
"capabilities": [
{ "type": "sql.Db[ReadWrite, schema: \"orders\"]", "constructed_at": "app.main:17", "assumes": ["role orders_rw privileges unchanged since connect"] }
]
}The report is the reviewer’s primary artefact for a path. It has a human-readable rendering with the same content; the JSON is normative. changed: M8, docs/CHANGES.md item 79 — the v0 report also carries `effects.forbid`, `obligations.failed`, `ok`, and `permitted_by` is `"scope"`, `"except"` or null; `unresolvable_calls` entries are `{ at, reason }`; a capability's `assumes` list is empty until construction-site assumptions exist changed: M10, docs/CHANGES.md item 83 — `graph`, `gates`, `recovers` and `ledger` carry what the path view draws
10. Errors
10.1 Expected failure
Result[T, E] and Option[T] are the only mechanism for expected failure. Error types are unions.
try e unwraps a Result[T, E]: on Ok(value: v) it yields v; on Err(error: x) it returns from the enclosing function. changed: M1, arguments are named at every call (§5); Result's fields are value and error Two forms:
try e— the enclosing function’s return type must beResult[_, E]with the sameE. The error is returned as-is.try e else name: expr—namebinds the error value;exprproduces the enclosing function’s error type. This is the only conversion mechanism; there is no implicitFrom.
let who: auth.AuthedCustomer = try auth.require(...) else e: Unauthorised(detail: e)Option[T] supports try in a function returning Option[_], and try ... else name: expr where name is bound to Unit.
10.2 Panics
A checked obligation that fails at runtime panics. Panicking unwinds to the nearest enclosing recover block or, absent one, terminates the program with the failed obligation in the exit report.
let outcome: Result[Receipt, Panicked] = recover {
handle_checkout(req: req, clock: clock, db: db, pay: pay, auth: auth)
}Rules:
recover { ... }yieldsResult[T, Panicked]whereTis the type of the block’s final expression; the block may notreturn.Panickedcarries the obligation’s location and counterexample, not a message string. changed: M2, docs/CHANGES.md item 41- A
recoverblock absorbs thepaniceffect of its contents: the enclosing function need not declarepanicfor what happens inside. inoutparameters are not visible insiderecover(unwinding would leave them in an unknown state). Capabilities are: a resource is not damaged by a panic in the code using it.- Every
recoversite appears in the module interface and the path report. A path may declareforbid { recover }to require that nothing on it swallows a failed obligation.
This is the request-boundary mechanism for long-running processes: a server loop wraps each request in recover, one bad row does not take the process down, and the reviewer can see exactly where that decision was made.
10.3 Absent
There is no throw, no catch, no finally, no defer. Resource release is by scope (§8.3).
11. Modules and the interface
module reporting
import std.sql
import app.configModule a.b.c lives at a/b/c.onus under the project root; std.* is the standard library and may not be declared elsewhere. Every module implicitly sees the public types and variants of the prelude modules (Result, Option, List, Grid, Map and the primitives’ companions) without importing them; functions are reached as Type.f (List.len, Int.to_text), which names function f of the module declaring Type. In a dotted name, an import alias takes precedence over a local of the same name. changed: M2, docs/CHANGES.md items 27–31
A module’s interface is generated by the compiler and is the artefact a reviewer reads. It contains, for every public item:
- the signature, including effects and claims
- all
requires,ensures,invariantandlawclauses - all
exampleandpropertyblocks and their status - every
assumein the module, with its justification - the obligation ledger: each obligation, its location, and whether it was proved, checked, or assumed
Bodies are not in the interface. If an interface is insufficient to trust a module, that is a defect in the module’s contracts, not a reason to read the body.
11.1 Interface document format
onus interface <module> --json is normative; onus interface <module> renders it as text in canonical source syntax with bodies elided to { ... }. The JSON shape:
{
"module": "app.reporting",
"hash": "b3:4a1e...",
"imports": ["std.sql", "std.io", "app.config"],
"items": [
{
"kind": "fn", "name": "monthly_totals", "visibility": "pub",
"signature": "fn monthly_totals(db: sql.Db[ReadOnly, schema: \"orders\"], year: Int where 2000 <= it and it <= 2100) -> Result[List[MonthlyTotal], sql.Error] may sql.read, alloc",
"effects": ["sql.read", "alloc"], "claims": [],
"contracts": [ { "kind": "ensures", "text": "forall t: MonthlyTotal in result: t.total_pence >= 0", "status": "checked", "checked_at": "std.sql.query#row_refinement" } ],
"examples": [], "properties": [],
"assumes": [], "recovers": [],
"obligations": { "proved": 4, "checked": 1, "assumed": 0 }
}
],
"assumes": [], "recovers": [], "sealed_types": [], "test_module": false
}Every item carries its own obligation counts; the module totals are the sum. Diffing two interface documents is how the compiler enforces the compatibility rule below. changed: M10, docs/CHANGES.md items 84–85 — each item carries `at`; `onus interface --diff` produces the diff document, deciding compatibility textually in v0
Visibility: items are private unless marked pub. Private items may have weaker contracts; public ones may not be weakened once published without a major version change (the compiler diffs interfaces and refuses a compatible-version bump that weakens a contract or widens an effect set).
12. Verification
12.1 Obligations
Every requires at a call site, every ensures at a return, every refinement at a binding, every invariant at loop boundaries, every decreases, every index bound, every law on an impl, and every property generates an obligation.
12.2 States
| State | Meaning |
|---|---|
| proved | Discharged by the verifier from the decidable fragment. No runtime cost. |
| checked | Not provable; a runtime check is inserted. Requires the panic effect. |
| assumed | Introduced by assume, or the contract of an intrinsic (§3.12). Recorded, never checked. changed: M2 |
The compiler never silently downgrades. A function may pin an obligation’s state (proved on a requires clause means “fail compilation if this cannot be proved”), and paths may require that no obligation on them is checked.
12.3 Fragment (provisional)
Linear integer arithmetic, equality with uninterpreted functions, algebraic datatypes, and bounded quantification over finite ranges. Nonlinear integer arithmetic is attempted with a fixed low budget and falls back to checked. Real arithmetic is excluded by default. changed: M6, docs/CHANGES.md item 64 — v0 encodes records and unions with uninterpreted projections and tags rather than solver datatypes, and treats Float values as opaque
Solver timeouts are hard errors (E0501 verification budget exceeded), not silent fallbacks, because a loop that behaves differently on repeated runs is worse than one that fails consistently. Each obligation carries a per-obligation budget; the default is small and may be raised explicitly with budget annotations that appear in the interface.
12.4 Caching
Obligations are keyed by the content hash of the canonical text of everything they depend on. Re-checking after an edit re-verifies only obligations whose dependency hashes changed. changed: M6, docs/CHANGES.md item 69 — v0 keys the cache by the solver problem text, the solver version and the budget, which is a function of the same dependencies
13. Diagnostics protocol
onus check --json emits a stream of diagnostic objects. There are no warnings; every diagnostic is an error, and the compiler reports all of them, not the first.
{
"code": "E0302",
"title": "postcondition not established",
"location": { "file": "mandelbrot.onus", "def": "escape_count", "span": [[14, 3], [14, 11]] },
"obligation": {
"kind": "ensures",
"text": "result <= limit",
"status": "unprovable",
"counterexample": { "limit": 1, "i": 2 }
},
"context": [
"invariant i <= limit is not maintained: i is incremented after the loop condition is checked"
],
"repairs": [
{ "kind": "replace", "span": [[9, 14], [9, 23]], "with": "i < limit", "confidence": "high" },
{ "kind": "insert", "after": [[10, 0]], "text": " invariant i <= limit", "confidence": "medium" }
],
"canonical_hash": "b3:9f2c..."
}Required fields for every diagnostic: a stable code, the innermost def, a span, the violated obligation where applicable, a machine-checkable counterexample when the solver produced one, and zero or more repairs that are syntactically valid and, when applied, produce canonical source. The full code catalogue is part of the language specification and is versioned with it.
14. Constrained decoding interface
onus next --at <file>:<offset> returns the set of legal next tokens at a position, computed from the LL(1) parse state and — where the position is in an expression — from the type checker’s expected type, so that a decoder can mask logits at each step. The interface is streaming and incremental; the compiler keeps the parse and type state for a file resident between calls.
Type-constrained masking is exact for expected types and best-effort for refinements (the decoder is allowed to emit a value that later fails a refinement obligation, which the diagnostic loop then catches). changed: M9, docs/CHANGES.md item 81 — v0 is `onus next
15. The review tool
Onus assumes the developer is reviewing, not editing. The review tool is the surface for that work. It has one design rule: it computes nothing. Every view is a rendering of compiler output — interface documents (§11.1), path reports (§9.1), diagnostics (§13) — so there is no second analysis that can disagree with the language. Its outputs are decisions (approve, reject, request change) and contract edits, which return to the model as tasks.
15.1 Views
Path view. The reachable graph from a path entry, laid out top-down as authority flows: root capabilities enter at main, attenuate at each narrowing, and terminate at resources. Edges carry effects; nodes carry claims and obligation counts. assume leaves and recover sites are the only elements rendered in colour. An unresolvable call is drawn as a break in the graph.
Interface view. A module as its signatures, contracts, examples and properties, with obligation status inline: proved is unmarked, checked is marked with the runtime check’s location, assumed with its justification. Bodies are collapsed to { ... }. Expanding one is permitted and counted; body-open rate per module is reported, because a module whose bodies must be read has contracts that are not doing their job.
Ledger view. Every obligation across a module or path, filterable by state, plus every assume, every recover, and every capability construction site with the configuration it depends on. This answers “what am I actually trusting.”
Diff view. Two interface documents compared. Source diffs are not shown. What is shown: added or removed items, changed signatures, widened effect sets, weakened requires/strengthened ensures (compatible) versus the reverse (breaking), new assumptions, new recover sites, obligations whose state moved between proved, checked and assumed. This is the pull-request page.
Counterexample view. A failed obligation with the solver’s model rendered as concrete values against the contract text, and the path condition that led there. Its purpose is the one judgement that needs a human: whether the contract is wrong or the body is.
Promotion. When a reviewer identifies a convention the code follows but nothing enforces, the tool drafts the enforcing declaration — a sealed type, a derived claim, a path clause, a policy — and files it as a change. This is the mechanism by which the underspecified remainder (§17) shrinks over time.
15.2 What it does not do
- Edit bodies. Body changes are model work, requested through the tool as tasks with the failing obligation attached.
- Run anything. Examples and properties are evaluated by the compiler; the tool shows results.
- Offer opinions. No lint, no style, no suggestions beyond promotion drafts, which are always derived from something the reviewer pointed at.
16. Standard library (provisional)
Small, fully contracted, every function accompanied by examples. It is the corpus a model learns idioms from, so every function is also a worked example. changed: 2026-09-05, docs/CHANGES.md item 122 — grown for the compiler in Onus: text by code points, parsing, list builders and combinators, file reading and a console v0 scope: Int, Float, Text, List, Map, Option, Result, Grid, io.* capabilities, sql (connect, narrow, restrict, deadline, query, execute), config.
17. Open questions
Recorded rather than resolved.
- Concurrency. Structured concurrency over immutable inputs with channels by value is the obvious fit; cancellation and deadlock have no proposed mechanism. Concurrency, when designed, must fit both a single-threaded event-loop host and a native multi-threaded one; structured concurrency over immutable inputs with channels by value is the working assumption. changed: 2026-09-03, docs/CHANGE-LOG.md "Targets"
- Trace properties on paths. “Auth before any
sql.read” is expressible by typestate (see §18.3) for most cases. Genuine ordering properties over traces need a model checker or temporal claims; deferred. - FFI. Anything beyond
assumeat the boundary — marshalling, foreign effects, resource lifetimes — is unspecified. - Float verification. Whether to pursue real-arithmetic proving at all, or commit to runtime checks for floats permanently.
- Interface evolution. The contract-weakening rule in §11 is stated; the mechanism for deprecation and migration is not.
- Grammar. §2.3 is a first EBNF and has not been run through a parser generator. The LL(1) claim is unverified.
- Ownership escalation. If arenas-and-indices prove insufficient for graph-shaped data, a linear ownership system is the fallback; not designed.
- Nested patterns and deeper quantifiers. Both capped in v0 (§3.9, §5.3) for decidability and grammar simplicity. Lift when a real program needs them.
- Units of measure. F#-style phantom units on numeric types. Fits the type system without touching the verifier; deferred to keep v0 small.
- The underspecified remainder. Conventions inferred from a codebase (“like the existing handlers”) stay in prose and review until someone promotes them to a claim or path rule. The language should make that promotion cheap; how is not yet designed.
18. Worked examples
18.1 Mandelbrot
changed: M1, the code below is examples/mandelbrot/… in canonical form: named arguments, single-line literals, layoutDemonstrates: refinements, dependent records, contracts, loop invariants and termination, indexed types, examples and properties, the effect boundary.
module mandelbrot
import std.io
type Iter = Int where 0 <= it and it <= 10000
type Coord = Int where 0 <= it
record Viewport {
x_min: Float
x_max: Float where it > x_min
y_min: Float
y_max: Float where it > y_min
}
pub fn escape_count(cx: Float, cy: Float, limit: Iter) -> Iter
requires limit > 0
ensures result <= limit
{
var zx: Float = 0.0
var zy: Float = 0.0
var i: Iter = 0
loop while i < limit and zx * zx + zy * zy <= 4.0
invariant i <= limit
decreases limit - i
{
let nx: Float = zx * zx - zy * zy + cx
zy = 2.0 * zx * zy + cy
zx = nx
i = i + 1
}
return i
}
example escape_count {
escape_count(cx: 0.0, cy: 0.0, limit: 100) == 100
escape_count(cx: 2.0, cy: 2.0, limit: 100) == 1
}
property escape_bounded(cx: Float, cy: Float, limit: Iter where it > 0) {
escape_count(cx: cx, cy: cy, limit: limit) <= limit
}
pub fn render(
view: Viewport,
width: Coord where it > 0,
height: Coord where it > 0,
limit: Iter where it > 0
) -> Grid[Iter, width, height] may alloc {
var grid: Grid[Iter, width, height] = Grid.filled(value: 0, width: width, height: height)
for py: Int in 0 ..< height {
for px: Int in 0 ..< width {
let cx: Float = view.x_min + (view.x_max - view.x_min) * Float.of(x: px) / Float.of(x: width)
let cy: Float = view.y_min + (view.y_max - view.y_min) * Float.of(x: py) / Float.of(x: height)
Grid.set(grid: inout grid, x: px, y: py, value: escape_count(cx: cx, cy: cy, limit: limit))
}
}
return grid
}
pub fn main(args: List[Text], files: io.Files) -> Result[Unit, io.Error] may io.file, alloc {
-- files is a root capability supplied by the runtime (§8.3)
let view: Viewport = Viewport { x_min: -2.5, x_max: 1.0, y_min: -1.0, y_max: 1.0 }
let grid: Grid[Iter, 800, 600] = render(view: view, width: 800, height: 600, limit: 255)
let out: io.File = try io.create(files: files, path: "mandelbrot.pgm")
try io.write(file: out, text: "P2\n800 600\n255\n")
for py: Int in 0 ..< 600 {
for px: Int in 0 ..< 800 {
try io.write(file: out, text: Int.to_text(x: Grid.get(grid: grid, x: px, y: py)) ++ " ")
}
try io.write(file: out, text: "\n")
}
return Ok(value: Unit)
}Ledger, abridged. escape_count.ensures result <= limit: proved from the invariant. render index bounds on Grid.set: proved from the for ranges against the grid’s indices. The Viewport refinements at construction in main: proved (constants). The loop condition zx*zx + zy*zy <= 4.0 is a Float expression and generates no obligation; it is simply evaluated. Int.to_text and Float.of are total. No obligation is checked, so no function needs panic.
18.2 Monthly report (SQL)
changed: M1, the code below is examples/reporting/… in canonical form: named arguments, single-line literals, layoutDemonstrates: capabilities, construction from configuration, attenuation, the assumed leaf at the root, a path that forbids writes.
module reporting
import std.sql
import std.io
import app.config
record MonthlyTotal {
month: Text
total_pence: Int where it >= 0
}
union AppError =
| Config of detail: config.Error
| Storage of detail: sql.Error
| Io of detail: io.Error
pub fn monthly_totals(
db: sql.Db[ReadOnly, schema: "orders"],
year: Int where 2000 <= it and it <= 2100
) -> Result[List[MonthlyTotal], sql.Error] may sql.read, alloc
ensures forall t: MonthlyTotal in result: t.total_pence >= 0
{
-- text is a const parameter (§3.8.1): std.sql's own parse_select runs at check time and rejects anything but a single SELECT.
let stmt: sql.Select[MonthlyTotal] = sql.select[
text: "select to_char(created, 'YYYY-MM') as month, sum(amount_pence) as total_pence from orders where extract(year from created) = $1 group by 1 order by 1"
](params: [sql.int(x: year)], row: MonthlyTotal)
return sql.query(db: db, statement: stmt)
}
-- Root capabilities are supplied by the runtime to main and nowhere else (§8.3).
pub fn main(
args: List[Text],
env: io.Env,
files: io.Files,
net: io.Net
) -> Result[Unit, AppError] may io.env, io.file, io.net, sql.read, alloc {
let cfg: config.Config = try config.load(env: env) else e: Config(detail: e)
let reporting: sql.Db[ReadOnly] = try sql.connect(
net: net,
dsn: cfg.reporting_dsn,
mode: ReadOnly
) else e: Storage(detail: e)
let orders: sql.Db[ReadOnly, schema: "orders"] = sql.restrict(db: reporting, schema: "orders")
let rows: List[MonthlyTotal] = try monthly_totals(db: orders, year: 2026)
else e: Storage(detail: e)
let out: io.File = try io.create(files: files, path: "report.csv") else e: Io(detail: e)
for row: MonthlyTotal in rows {
try io.write(file: out, text: row.month ++ "," ++ Int.to_text(x: row.total_pence) ++ "\n")
else e: Io(detail: e)
}
return Ok(value: Unit)
}
path monthly_report
entry monthly_totals
effects <= { sql.read, alloc }
forbid { sql.write, io.net, io.file }What the compiler establishes. sql.select takes its statement as a const parameter and requires sql.parse_select — a const fn shipped by the library, not a compiler feature — to accept it as a single SELECT, so Select[T] cannot carry a write. The same const fn projects the column list and checks it against MonthlyTotal’s fields, so a typo in a column name is a compile error at the character in the string. sql.query requires a Db[ReadOnly, ...] and therefore satisfies sql.read only. The path check confirms nothing reachable from monthly_totals can write or perform other IO. The ensures on monthly_totals is checked, not proved: it depends on database contents, and the library inserts a row-level refinement check when decoding MonthlyTotal (total_pence: Int where it >= 0), so monthly_totals must declare panic or — as written — the library’s decode returns sql.Error.Refinement instead, keeping the function panic-free.
Ledger, the interesting entry. sql.read on path monthly_report rests on one assumption, at main, line 18: the role in cfg.reporting_dsn is read-only. sql.connect(mode: ReadOnly) sets the session read-only and verifies the role’s privileges at connect time; what remains assumed is that the privileges are not changed afterwards. The reviewer reads that one line and knows where the guarantee ends.
18.3 Checkout endpoint
changed: M1, the code below is examples/checkout/… in canonical form: named arguments, single-line literals, layout changed: M8, docs/CHANGES.md item 80 — `Idempotent` is declared in `app.contracts` (imported by checkout, app.auth and vendor.payments), `auth.require` claims it, and `load_basket` claims it with an `assume` that a select reads only; the path passes with three assumptions, of which exactly one is externalDemonstrates: typestate for ordering (“auth before data”), asserted claims and their assume leaves, a path with a policy, Result error composition.
module checkout
import std.sql
import std.io
import app.auth
import vendor.payments
-- auth.AuthedCustomer is `pub sealed` (§3.10): readable anywhere, constructible only inside app.auth.
-- The only producer is auth.require, so any function demanding one is callable only after a successful auth check.
pub fn recent_orders(
db: sql.Db[ReadOnly, schema: "orders"],
who: auth.AuthedCustomer
) -> Result[List[Order], sql.Error] may sql.read, alloc
ensures forall o: Order in result: o.customer == who.id
{
let stmt: sql.Select[Order] = sql.select[
text: "select * from orders where customer_id = $1 order by created desc limit 50"
](params: [sql.text(x: who.id)], row: Order)
return sql.query(db: db, statement: stmt)
}
union CheckoutError =
| Unauthorised of detail: auth.Error
| Storage of detail: sql.Error
| Payment of detail: payments.Error
| Empty
pub fn handle_checkout(
req: Request,
clock: io.Clock,
db: sql.Db[ReadWrite, schema: "orders"],
pay: payments.Client,
auth: auth.Service
) -> Result[Receipt, CheckoutError] may sql.read, sql.write, io.net, io.clock, alloc
claims Idempotent
{
let who: auth.AuthedCustomer = try auth.require(
service: auth,
caller: req.caller,
customer: req.customer,
clock: clock
) else e: Unauthorised(detail: e)
let basket: Basket = try load_basket(db: sql.narrow(db: db, to: ReadOnly), who: who)
else e: Storage(detail: e)
if List.is_empty(xs: basket.items) {
return Err(error: Empty)
}
let receipt: Receipt = try payments.charge(
client: pay,
key: req.idempotency_key,
amount: basket.total,
who: who
) else e: Payment(detail: e)
try record_order(db: db, who: who, basket: basket, receipt: receipt) else e: Storage(detail: e)
return Ok(value: receipt)
}
path checkout
entry handle_checkout
effects <= { sql.read, sql.write, io.net, io.clock, alloc }
require { Idempotent }
policy no_third_party_assumes except { vendor.payments.charge }Ordering without a trace property. recent_orders, load_basket and record_order all take an auth.AuthedCustomer. That type is produced only by auth.require, so the compiler cannot type a call to any of them before a successful auth check has run. “Auth before data” is not a rule anyone has to remember; it is not expressible otherwise.
The asserted claim. handle_checkout claims Idempotent. Propagation requires every effectful callee to claim it too. record_order claims it by construction (an insert ... on conflict (receipt_id) do nothing, which the library treats as a derived claim over the statement). payments.charge claims it with an assume whose justification cites the vendor’s contract. The path’s policy forbids third-party assumptions except that one, named explicitly — so the reviewer sees exactly one external claim they are trusting on this path, and where it is written down.
Panics. sql.query declares panic for row refinement failures. handle_checkout does not: the library converts them to sql.Error.Refinement before they reach it. The HTTP server loop that calls handle_checkout wraps each request in recover (§10.2), and that site is listed in the path report; the path itself declares nothing about recover, so a reviewer who wants a stricter guarantee adds forbid { recover } and sees it fail on the server loop.
What the ledger says. Reachable functions: 9. Aggregate effects within bound. Obligations: 31 proved, 2 checked (both Int refinements on decoded rows, inside sql.query, which declares panic — surfaced as sql.Error.Refinement instead), 1 assumed (vendor.payments.charge: Idempotent). The ensures on recent_orders is proved from the statement’s where clause: parse_select returns a Spec for it (§3.8.1), and sql.query’s postcondition states that every returned row satisfies that spec.
19. Targets
changed: 2026-09-03, docs/CHANGE-LOG.md "Targets: dual backends as a design goal"; implemented from M11 — 2026-09-04: M11 done for the subset in docs/CHANGES.md item 95; `E0800` and `E0801` exist; host claims, `Int` representation obligations, `sql` and WebAssembly are M12An Onus program compiles unchanged to every supported target. Observable behaviour is defined by this specification, never by the host. Where this specification is silent on something a program can observe, that is a defect in the specification.
19.1 Runtime primitive surface
Each target provides a runtime implementing exactly the following primitives. Everything else in std.* is written in Onus and compiled by the same backend as user code.
- Memory: allocate, free-at-scope-exit (native) or no-op (collected hosts).
Int: 64-bit signed arithmetic with overflow detection; see 19.3.Float: IEEE 754 binary64 arithmetic;classify; formatting to text per the algorithm instd.float(shortest round-trip representation).Text: UTF-8 storage; grapheme-cluster segmentation per Unicode 16.0 (pinned; the runtime carries the tables); byte and grapheme views; equality by code point sequence.Bytes: contiguous byte sequence with bounds-checked access.- Panic: raise with an obligation id and optional model;
recoverboundary. - Capabilities: opaque handles for
io.Files,io.Env,io.Net,io.Clock,io.Rand,sql.Db, plus the__fakeconstructor available only to test modules. io.*andsql.*raw calls, each mapped one-to-one from anassumeleaf instd.io/std.sql.
The primitive surface is versioned with the specification. A backend that lacks a primitive reports E0800 primitive unavailable on target at build time for any program reaching it.
19.2 Host claims
Code that can only run on one host declares it with a claim: host.js, host.native, host.wasm. These are asserted claims (§7.1) introduced only at assume leaves that call host-specific facilities, and they propagate like any claim. A path may forbid { host.js, host.native, host.wasm } to require portability, and the compiler then rejects anything reachable that depends on a host. changed: 2026-09-05, docs/CHANGES.md item 98 — the claims live in `std.host` and the JavaScript-only intrinsics carry `claims host.js`; a `forbid` clause may name claims; the native target refuses a reached function carrying `host.js` with `E0800`
19.3 Integer representation
Int is 64-bit signed on every target. On targets without native 64-bit integers (JavaScript), the backend chooses a representation per value: a double-precision number where the verifier has proved |x| <= 2^53 - 1 for every value the binding can hold, and an arbitrary-precision integer otherwise. The choice appears in the ledger as an obligation of kind representation, so a reviewer can see which values are running on the slow path and tighten refinements to move them. changed: 2026-09-05, docs/CHANGES.md item 99 — the obligations are generated, verified and reported; the arbitrary-precision path is not implemented, and a checked binding's overflow checks panic instead
19.4 Fully specified behaviour
The following are specified so that all targets agree: Map iteration is in key order under Ord[K]; integer division truncates toward zero and % takes the sign of the dividend; Float to Text is the shortest round-trip form; example blocks run in source order; Stream elements are produced on demand and never buffered beyond one element by the runtime.
19.5 Differential testing
Every example and property runs on every built target. Any disagreement between targets on a program the compiler accepted is a backend defect, reported as E0801 target disagreement with the example, the two results and the targets. changed: 2026-09-05, docs/CHANGES.md item 104 — v0 runs examples on both targets (`onus test --target all`); properties run on JavaScript only
20. Testing
changed: 2026-09-03, docs/CHANGE-LOG-02.md "Testing model"; code pending (M8 and M10 additions, M13)20.1 What is tested and where
| Concern | Mechanism | Lives in |
|---|---|---|
| Behaviour, all inputs | requires / ensures / invariant, proved | the function’s interface |
| Behaviour, specific inputs | example (§5.2) | the function’s interface |
| Behaviour, generated inputs | property (§5.2) | the function’s interface |
| Dependencies | capabilities passed as parameters; fake (§8.4) | test module |
| Scenarios across modules | example blocks in a test module, with fakes at the edges | test module |
| Contact with reality | verify blocks on assume leaves (20.2) | next to the assumption |
| Regressions | example blocks pinned from counterexamples (loop spec §7) | the function’s interface |
| Strength of the contracts | regeneration audits (loop spec §8) and contract mutation (20.4) | onus test |
There is no test tree parallel to the source. An example is attached to what it exemplifies.
Functions with the nondet effect take their source of nondeterminism (io.Clock, io.Rand) as a capability, so a test supplies a fixed one. A test that could be flaky is not expressible.
20.2 Assumption verification
An assume may carry a verify block: an Onus function body that exercises the assumption against the real resource and yields Bool.
assume Idempotent "Vendor API deduplicates on req.key for 24h; see contract §4.2"
verify(client: payments.Client) may io.net, alloc {
let a: Receipt = try payments.charge(client: client, key: "verify-1", amount: 100) else _: false
let b: Receipt = try payments.charge(client: client, key: "verify-1", amount: 100) else _: false
a.id == b.id
}- The block’s parameters are capabilities, supplied by the environment running
onus test --assumptions, never constructed by the block. - The block declares its effects like any function and may not exceed the effects of the function containing the
assume. verifyblocks are never run byonus check; they run only underonus test --assumptions, which is expected to be pointed at a staging or test environment.- An
assumewithout averifyblock is permitted and is reported as unverifiable in the ledger.
20.3 Ledger fields
Each assume entry in the ledger (§9.1, §11.1) gains:
verifiable: bool— whether averifyblock exists.last_verified: { at: timestamp, target: string, result: "passed" | "failed" } | null— recorded byonus test --assumptions, persisted in.onus/ledger/.
The review tool shows assumptions as assumed, verified path may require policy verified_assumptions_only, which fails the build if any reachable assume lacks a passing verification within a repository-configured age. changed: 2026-09-04, docs/CHANGES.md item 90 — the key is the module name and the BLAKE3 of the assumption's canonical text, so a record survives moves and lapses when the assumption changes; the file is `.onus/ledger/assumptions.json`; the age is `onus.json` `test.max_assumption_age_days` (default 7); the policy is `E0416`
20.4 Contract mutation
onus test --mutate weakens contracts one at a time and reports which weakenings no example or property detects. Mutations applied, per obligation: drop an ensures clause; replace a refinement bound with its base type; negate a guard in a property; drop a law. A mutation that survives — every example and property still passes — is reported as M0001 undetected contract weakening with the mutation and the function. It is not an error; it is the signal that the examples are not carrying the contract’s meaning. changed: 2026-09-05, docs/CHANGES.md items 106–107 — a weakening never changes a body, so "survives" means no assertion the verifier proved from the contracts stops being provable without the clause; guard negation re-runs the property over the complement of its domain; laws are not dropped and parameter refinements are not widened
Mutation never touches bodies. Bodies are the model’s; weakening them is what the loop already does implicitly by regenerating.
20.5 Obligation coverage
The reported test metric is obligation coverage, per module and per path:
- obligations proved;
- obligations checked, and of those, how many are exercised by at least one
exampleorproperty; - assumptions, and of those, how many are verifiable and how many have a current passing verification;
- contract mutations detected versus surviving.
Line coverage is not reported and cannot be enabled. changed: 2026-09-05, docs/CHANGES.md item 108 — reported as `obligation_coverage` in `interface.json`, `path.json` and the review page, from the hits `onus test` records on the JavaScript target
20.6 The runner
onus test evaluates example and property blocks (already done by onus check), runs test modules, and on multi-target builds runs everything on each target, reporting disagreement as E0801 (§19.5). onus test --assumptions runs verify blocks against supplied capabilities. onus test --mutate runs contract mutation. There is no plugin mechanism and no configuration file beyond the repository’s target and environment settings. changed: 2026-09-04, docs/CHANGES.md item 91 — v0: `onus test
End of v0.