Contracts and formal verification in Nova
Nova’s contract system lets you state what a function requires and
ensures, then verifies those claims at compile time via an SMT solver.
Nova uses enforce-with-elision (D24 / Plan 140), not debug-only asserts:
a proven contract is elided (zero runtime cost, even in debug); an
unproven one is enforced at runtime in both debug and release
(fail-fast nova_contract_violation abort, never silent UB). You opt out of
an unproven check only explicitly — per-fn #unchecked or build-policy
--contracts=off. The opt-out has three scopes (Plan 140.3): #unchecked on
a fn, #unchecked on a module (#unchecked before module X), or the
build flag; and Eiffel-style per-kind granularity — #unchecked(requires) /
#unchecked(ensures) / #unchecked(invariant) (combinable, fn or module) elide
only the listed kinds. Without the SMT backend the proven set is empty, so every
contract is checked (safe degrade, slower, not unsafe).
A contract violation — like an assert failure — is panic-class: caught by a
consume/supervised scope it is classified as a Panic, not a recoverable
Failure (Plan 140.3 / D13). A requires message may interpolate runtime
values with ${...} — requires x > 0, "got ${x}" renders got -5 at the
failing call (the message is built only on violation, never on the passing path;
Plan 140.3).
Spec: D24
(SMT strategy) ·
D111
(assume / assert_static / #trusted) ·
D112
(bounded quantifiers) ·
D116
(Z3 backend).
When contracts are checked: at compile time and at run time
A contract has two independent lines of defence.
At run time — always. requires, ensures and invariant are checked while
the program runs. This needs no flags and cannot be turned off: a violated
contract stops the program with a diagnostic.
At compile time — on request. The compiler can PROVE a contract via an SMT solver. A proven contract buys exactly one thing: its run-time check can be removed from the finished program — it is true by construction, so there is nothing left to check.
As of 0.1 proving is off by default and is enabled by a flag:
nova check --verify # prove contracts while checking
nova build --verify # prove, and drop proven checks from the binary
Why it is off by default
Proving is expensive work, and paying for it on every build is pointless: on ordinary code without contracts it finds nothing and still costs time. Turn it on where it pays off — in release builds where the finished program’s speed matters, and when you want to confirm a contract is genuinely provable rather than merely “has not failed yet”.
What this means for safety
Nothing. The flag affects compile speed and the speed of the finished program, not its correctness:
- without
--verify— no contract is proven, so every check stays in the program and runs at execution time; - with
--verify— proven checks are dropped as true by construction, unproven ones remain.
So omitting the flag makes the program slower, never less safe. The converse does not hold either: the flag cannot “miss” a violation — whatever is not proven is checked at run time.
Quickstart
// Simple precondition + postcondition.
#verify
fn withdraw(balance int, amount int) -> int
requires amount > 0 && amount <= balance
ensures result == balance - amount
ensures result >= 0
{
balance - amount
}
test "contracts quickstart: withdraw" {
assert(withdraw(100, 30) == 70)
assert(withdraw(50, 50) == 0)
}
// REQUIRES_SMT_BACKEND z3
// Opaque helper + reveal in caller — Z3 proves the stronger contract.
#opaque #pure
fn double(x int) -> int
requires x >= 0
ensures result >= 0
=> x * 2
#verify
fn caller_with_reveal(n int) -> int
requires n >= 0
ensures result == n * 2
{
reveal double
double(n)
}
test "contracts quickstart: opaque + reveal" {
assert(double(5) == 10)
assert(caller_with_reveal(7) == 14)
}
Contract clauses
Contract clauses appear between the parameter list and the { body
(or => expression body). Multiple clauses of the same kind are
allowed and are conjoined.
requires
A precondition. The SMT solver assumes it holds when verifying the body. Callers must satisfy it.
#verify
fn safe_div(a int, b int) -> int
requires b != 0
ensures result * b == a - (a % b)
{
a / b
}
Multiple requires clauses are equivalent to a single conjunction:
#verify
fn clamp(x int, lo int, hi int) -> int
requires lo <= hi
ensures result >= lo && result <= hi
{
if x < lo { lo } else if x > hi { hi } else { x }
}
Range conditions: use &&, never a chain
To constrain a value to a half-open range, write the canonical conjunction
lo <= i && i < hi — not lo <= i < hi:
fn at(buf []int, i int) -> int
requires 0 <= i && i < buf.len // ✓ a real bounds check
=> buf[i]
A chained comparison such as 0 <= i < hi is a compile error
(E_CMP_CHAIN_UNSUPPORTED) in Nova. It would otherwise parse as
(0 <= i) < hi = bool < hi, which is vacuously true — silently turning the
bounds contract into a no-op. Nova rejects the chain (and bool/unit operands of
< <= > >=, E_RELATIONAL_OPERAND_NOT_ORDERED) at parse/check time;
split into && as shown (Plan 150 / D248).
Self-access (@field, @len()) in method contracts
A method contract may refer to the receiver’s own state. Read a field
with @field, or a built-in size accessor with @len() / @cap() /
@byte_len() / @is_empty() (the call form is interchangeable with the
field, @len() ≡ @len):
fn Vec[T] @index(i int) -> T
requires 0 <= i && i < @len // ✓ refers to the receiver's length
{
unsafe { @data[i] }
}
The SMT solver models the receiver as an entity _self; each @field becomes
an uninterpreted _field_<name>(_self), so @len in requires and @len in
ensures denote the same term (consistent reasoning). Only reads are
allowed — a contract is an expression, so there is no way to write a field in
one.
When such a contract is unproven and fires at runtime, the violation message
renders the self-access readably — requires failed: 0 <= i && i < @len —
naming the actual field, not a placeholder (Plan 140.2 / D256 §Diagnostics).
Calling any method in a contract — @method() on the receiver, or
obj.method() on another value, including a chain (a.b().c()) — is encoded
as an uninterpreted function (UF), the same non-inlined path used for a
composed #pure free function: the UF’s name is derived from the method,
its receiver is the UF’s first argument. An unproven UF-condition is not a
compile error — it falls back to the ordinary runtime check (enforce-with-
elision), like any other unproven contract.
What is still required is that the called method be pure — no
effects, mut receiver, or non-terminating recursion without a
decreases. Purity is inferred the same way it is for a free function
(SCC analysis over the call graph, no attribute needed); an effectful
method in a contract remains a compile error. #pure is never required to
satisfy this — it exists for boundaries inference cannot reach (extern
fns) or as a voluntary explicit assertion.
Bounds as an elidable contract (Vec @index)
Vec[T] @index/mut @index carry requires 0 <= i && i < @len, so an
out-of-bounds v[i] is a contract violation. This makes bounds an elidable
contract (D257), following the same enforce-with-elision model as any contract:
- a provably in-bounds access compiles with no runtime check (zero-cost);
- an unproven access keeps the check and aborts on OOB (in debug and release) — never silent UB.
The verifier proves an access in-bounds when the index is bounded by, e.g.:
for i in 0 .. v.len() {
sum = sum + v[i] // proven: i ∈ [0, v.len()) → check elided
v[i] = v[i] * 2 // write-back also elided (in-place keeps length)
}
ro s = v[0 .. v.len()] // slice v[a..b]: 0<=a && a<=b && b<=v.len() proven
fn at(v Vec[int], i int) -> int
requires 0 <= i && i < v.len()
=> v[i] // cross-fn: bound comes from the `requires`
Elision needs the SMT backend (NOVA_SMT_BACKEND=z3); without it every access is
checked (safe degrade). It also needs the vector’s length to be invariant in
scope — a length-changing call (push/pop/…) on the same vector keeps the check
(soundness). For accesses proven only via a requires, the check is kept under
--contracts=off / #unchecked (where the requires is no longer enforced).
@get/@first/@last return Option and stay None on OOB — they carry no
bounds contract.
ensures and result
A postcondition. result refers to the return value of the function.
Multiple ensures clauses are all independently checked.
#verify
fn abs_val(x int) -> int
ensures result >= 0
ensures result == x || result == -x
{
if x >= 0 { x } else { -x }
}
old(...) in ensures
old(expr) captures the value of an expression at the function
entry point, before the body runs. Useful for mutation contracts.
#verify
fn increment(mut n int) -> int
ensures result == old(n) + 1
{
n = n + 1
n
}
decreases
Proves termination of recursive functions. The expression must strictly decrease on every recursive call. The SMT solver checks this as a well-foundedness obligation.
fn factorial(n int) -> int
requires n >= 0
decreases n
=> if n == 0 { 1 } else { n * factorial(n - 1) }
fn fib(n int) -> int
requires n >= 0
decreases n
=> if n < 2 { n } else { fib(n - 1) + fib(n - 2) }
Verification attributes
#verify
Strict SMT verification. The compiler encodes the function body and all contracts as an SMT query. If the solver cannot prove the contracts — unknown, timeout, or counterexample — compilation fails with an error (no runtime fallback).
Without #verify, contracts are still tried by the SMT solver, but
a failure only produces a warning W2401/W2402 and a runtime
check in debug — the build succeeds. #verify upgrades that to a
hard error.
History:
#verifywas called#must_verifybefore Plan 33.3, when it was renamed. Any old text mentioning#must_verifyon a function refers to#verify.
#verify
fn sum_nonneg(a int, b int) -> int
requires a >= 0
requires b >= 0
ensures result >= 0
{
a + b
}
#pure
Marks a function as pure — no side effects, no effects in its
row. Pure functions (and methods) can be called freely inside contract
expressions (requires/ensures/invariant), where effectful calls are
forbidden.
Purity does not require this attribute: it is inferred automatically
for any function or method whose body is effect-free (SCC analysis over
the call graph — const fn in Rust is the closest analogue). #pure is
a voluntary, explicit assertion; the compiler never demands it as a way to
unlock contract composition. It matters at boundaries inference cannot see
through — an extern/FFI function has no Nova body to analyze, so its
purity (if any) must be declared, not inferred.
// No `#pure` needed here — the body is effect-free, purity is inferred.
fn is_positive(x int) -> bool => x > 0
#verify
fn safe_log(x int) -> int
requires is_positive(x) // inferred-pure call allowed in contract
ensures result >= 0
{
x - 1
}
#unverified
Opts out of SMT verification (not enforcement). The contracts are
unproven, so they are enforced at runtime in both debug and release
(enforce-with-elision — nothing is elided). Use for contracts the solver
cannot handle (non-linear arithmetic, string predicates, etc.). To drop the
runtime check too, use #unchecked / --contracts=off.
#unverified
fn safe_double(x int) -> int
requires x > 0
ensures result == x * 2
=> x * 2
#must_verify (removed — use #verify)
#must_verifyno longer exists. It was renamed to#verifyin Plan 33.3. Writing#must_verifytoday is silently ignored by the parser (treated as an unknown attribute). Use#verifyinstead.
// Before Plan 33.3: #must_verify fn f(...) ...
// After Plan 33.3: #verify fn f(...) ... ← use this
#verify
fn transfer_total(from_bal int, to_bal int, amount int) -> int
requires amount > 0 && amount <= from_bal
ensures result == from_bal + to_bal
{
(from_bal - amount) + (to_bal + amount)
}
#trusted
Used in two contexts:
1. with #trusted on a handler binding — skips axiom verification
for that handler, accepts contracts as axioms on faith:
with #trusted Log = handler Log {
Write(msg) { if msg > 0 { buf = msg } else { buf = 0 } }
last() => buf
} { ... }
2. #trusted on a function containing assume — suppresses the
trust-introduced warning:
#trusted
fn call_ffi() -> int {
ro result = extern_fn()
assume result >= 0 // documented FFI postcondition
result
}
Pure function/method composition
Pure functions and methods compose freely in contract expressions — a free
function call is inlined (or UF-encoded if #opaque); a method call
(obj.method(), including a chain) is always UF-encoded, receiver as the
first argument. This lets you build reusable predicates without #pure,
as long as the body is effect-free (purity is inferred, see #pure above):
fn in_range(x int, lo int, hi int) -> bool => x >= lo && x <= hi
#verify
fn clamp_tight(x int) -> int
ensures in_range(result, 0, 100)
{
if x < 0 { 0 } else if x > 100 { 100 } else { x }
}
Effectful functions/methods in contracts are a compile error:
error: calling function `f` in a contract requires it to be pure
(purity is inferred automatically for effect-free bodies —
Plan 33.5 SCC inference, no attribute needed); `f` has effects
and cannot be used in a contract
Proof helpers
assert_static
Inserts an intermediate proof step visible to the SMT solver. Breaks a complex contract into smaller, independently verifiable facts. Proven → elided (zero-cost, debug and release); unproven → runtime check kept in debug and release (enforce-with-elision).
#verify
fn transfer(from int, to int, amount int) -> int
requires amount > 0 && amount <= from
ensures result == from + to
{
assert_static from - amount >= 0 // intermediate fact
(from - amount) + (to + amount)
}
assume
Injects a fact into the SMT context without proof. Use for
FFI postconditions or OS invariants the solver cannot see. Generates
warning trust-introduced unless inside a #trusted function.
#trusted
fn read_positive_from_device() -> int {
ro v = device_read()
assume v >= 0 // documented hardware guarantee
v
}
calc { ... }
A structured chain of equalities (or inequalities) that guides
the SMT solver step by step. Each step == expr; asserts equality
with the previous line. The solver checks each step independently.
#verify
fn double_is_double(x int) -> int
ensures result == x * 2
{
calc {
x * 2;
== x * 2;
}
x * 2
}
More complex chains can include arithmetic identities:
#verify
fn add_assoc_proof(a int, b int, c int) -> bool
ensures result == true
{
calc {
(a + b) + c;
== a + (b + c); // associativity — Z3 proves each step
}
true
}
Loop invariants
An invariant clause inside a loop body asserts a condition that
holds at every iteration entry. The SMT solver checks:
- The invariant holds before the loop (initialization).
- If the invariant holds at iteration start and the loop condition holds, then the invariant holds at the end of the body (inductive step).
// REQUIRES_SMT_BACKEND z3
#verify
fn sum_nonneg_array(n int) -> int
requires n >= 0
ensures result >= 0
{
mut sum = 0
mut i = 0
while i < n {
invariant sum >= 0
invariant i >= 0
sum = sum + i
i = i + 1
}
sum
}
The decreases clause can also appear on a loop to prove termination:
#verify
fn countdown(n int) -> int
requires n >= 0
ensures result == 0
{
mut k = n
while k > 0 {
invariant k >= 0
decreases k
k = k - 1
}
k
}
Lemmas and apply
A lemma is a #verify function whose purpose is to establish a
mathematical fact — it exists for its proof, not its runtime value.
Typically returns bool and has ensures result == true.
// REQUIRES_SMT_BACKEND z3
#verify
lemma add_comm(a int, b int) -> bool
ensures result == true
{
a + b == b + a
}
The apply statement injects the postcondition of a lemma as a fact
into the current SMT context. This lets you chain lemma results:
#verify
fn use_commutativity(a int, b int) -> int
requires a >= 0 && b >= 0
ensures result == b + a
{
apply add_comm(a, b) // injects: a + b == b + a
a + b
}
Rules:
applyonly works inside#verifyfunctions.- The lemma must already be proven (i.e.,
#verifyand its contracts checked without error). - Duplicate
applyof the same lemma in the same scope is a warningW2402.
Opaque functions and reveal
#opaque
#opaque on a #pure function hides its body from the SMT solver.
The solver treats it as an uninterpreted function (UF): it knows
the requires/ensures contracts but not the implementation.
This prevents matching-loop divergence in recursive functions and gives control over which callers get access to the body-level proof:
// REQUIRES_SMT_BACKEND z3
#opaque #pure
fn double(x int) -> int
requires x >= 0
ensures result >= 0
=> x * 2
Without reveal, a caller can only use the declared ensures
(result ≥ 0), not that result == x * 2:
// EXPECT_COMPILE_ERROR contract violation
#verify
fn caller_no_reveal(n int) -> int
requires n >= 0
ensures result == n * 2 // Z3 cannot prove — body is hidden
{
double(n)
}
reveal fn_name
reveal fn_name injects the body axiom of an #opaque function into
the current SMT scope. After reveal, the solver can use the full
body for proofs in that function:
// REQUIRES_SMT_BACKEND z3
#verify
fn caller_with_reveal(n int) -> int
requires n >= 0
ensures result == n * 2
{
reveal double // body axiom injected: double(x) == x * 2
double(n)
}
Scope: reveal is function-local. It does not affect other
callers.
Warnings:
W2402—revealin a non-#verifyfunction (no SMT context).W2402— duplicaterevealfor the same name in the same scope.W2403—revealfor a function that is not#opaque.
#fuel(n)
#fuel(n) on an #opaque #pure recursive function enables N
levels of unrolling in the SMT scope after reveal. Without fuel,
the opaque body axiom is non-recursive. With #fuel(2), the solver
gets two unrolling levels — enough to prove properties of small
concrete inputs:
// REQUIRES_SMT_BACKEND z3
#opaque #pure #fuel(2)
fn count_down(n int) -> int
requires n >= 0
ensures result >= 0
=>
if n == 0 { 0 } else { 1 + count_down(n - 1) }
#verify
fn prove_base_case() -> int
ensures result == 0
{
reveal count_down
count_down(0) // fuel unrolls: count_down(0) == 0
}
#verify
fn prove_one_step() -> int
ensures result == 1
{
reveal count_down
count_down(1) // fuel unrolls: 1 + count_down(0) == 1
}
The fuel chain works by creating N intermediate UFs and chaining
them via axioms, following Dafny’s approach.
Bounded quantifiers
Nova supports bounded quantifiers — forall/exists over
concrete collections or index ranges. Unbounded universal quantifiers
are a compile error.
// REQUIRES_SMT_BACKEND z3
#verify
fn all_nonneg_sum(a int, b int, c int) -> bool
requires a >= 0 && b >= 0 && c >= 0
ensures result == true
{
a + b + c >= 0
}
Syntax for bounded quantifiers in contracts:
// forall — universal
requires forall i in 0..xs.len() : xs[i] >= 0
// exists — existential
ensures exists i in 0..result.len() : result[i] == target
The collection after in must be an iterable ([]T, range, set,
map). The body must be bool and #pure.
Bit-vectors and overflow
Sized integer types — u8, u16, u32, u64, i8, i16, i32 —
are encoded into the SMT bit-vector theory instead of unbounded
integers. This gives precise machine semantics: arithmetic wraps around
(two’s complement), and bitwise operators are reasoned about exactly.
// REQUIRES_SMT_BACKEND z3
#verify
fn low_byte(x u32) -> u32
ensures result <= 255 as u32
=> x & 255 as u32
The plain int type stays an unbounded mathematical integer — it is
not a bit-vector. Use int for general-purpose arithmetic; use sized
types for low-level, packed, crypto, or FFI code where bit-width matters.
int overflow is a panic. Signed int arithmetic (+, -, *)
that would exceed the 64-bit range panics at runtime — it never
silently wraps. This is what makes verification of int contracts
sound: the verifier reasons about int as an unbounded mathematical
integer, and a proven ensures result == a + b holds for every value
the function actually returns — because if a + b would overflow, the
function panics instead of returning a wrong (wrapped) result. Sized
integer types wrap instead of panicking (see above); reach for
#nooverflow on them when wrap-around is not acceptable.
Proven-safe overflow checks are elided. Each int +/-/* compiles to an
always-on overflow check (nova_int_checked_*). When the Z3 backend proves the result
stays in the 64-bit range — from loop bounds, literals, or a requires — the check is
removed (zero-cost), exactly like an elided bounds check (D272, same enforce-with-
elision model). A loop-bounded i + j or a requires-bounded a + b emits a plain C
operator; an unprovable op keeps the check (debug and release). Elision is proof-
only — never triggered by #unchecked alone: a check proven only via requires is
kept under --contracts=off / #unchecked(requires). Needs NOVA_SMT_BACKEND=z3;
without it every op is checked. * is non-linear, so Z3 may leave it checked.
Bitwise operators &, |, ^, <<, >> are available in contracts
on sized-integer operands (they remain unsupported on int).
Signedness. Unsigned types (u8/u16/u32/u64) and signed types
(i8/i16/i32) differ in comparison, division, remainder and
right-shift. The verifier picks the correct operator from the parameter
type: i32 comparisons are signed (-1 < 0 holds), u32 comparisons
are unsigned (0xFFFFFFFF > 0). Signed division rounds toward zero;
>> on a signed value is an arithmetic shift.
Casts between sized types. x as u32 resizes a bit-vector: a wider
target zero-extends an unsigned source and sign-extends a signed source;
a narrower target truncates the low bits. For example (b as u32) where
b : u8 is always <= 255, and (x as u8) keeps only the low byte.
#nooverflow
By default, sized-integer arithmetic wraps around silently. The
#nooverflow attribute makes the verifier emit an extra proof
obligation for every +, -, * in the function body: the operation
must not overflow the type. An unprovable obligation is a compile error.
// REQUIRES_SMT_BACKEND z3
#nooverflow #verify
fn safe_add_u32(a u32, b u32) -> u32
requires a <= 1000 as u32 && b <= 1000 as u32
ensures result == a + b
=> a + b
Here the precondition bounds a and b so their sum cannot exceed
2^32 - 1 — the overflow obligation is discharged. Without a bounding
requires, a + b could overflow and #nooverflow rejects the
function at compile time.
#nooverflow requires an SMT backend with bit-vector support
(REQUIRES_SMT_BACKEND z3); the trivial backend reports the
bit-vector theory as unsupported.
Trusted external functions
An extern fn (extern "nova" fn / extern "C" fn) with contracts requires
#trusted. The contracts are
registered as axioms — callers receive the ensures as
assumptions without proof. The compiler does not verify the body
(there is no Nova body to check).
#trusted
extern "nova" fn libc_strlen(s str) -> int
requires s.is_valid_cstring()
ensures result >= 0
#verify
fn use_strlen(s str) -> int
requires s.is_valid_cstring()
ensures result >= 0
{
libc_strlen(s) // ensures from #trusted axiom injected
}
SMT backend selection
Nova has two verification backends:
| Backend | Activated by | Capabilities |
|---|---|---|
| Trivial | default | Constant-folding, linear bounds on single binary ops. Fast, no Z3 dependency. |
| Z3 | env NOVA_SMT_BACKEND=z3, or the --backend z3 flag on nova contracts verify | Full LIA + EUF + bounded arrays. Required for opaque/reveal, complex arithmetic chains, loop invariants. |
Tests that require Z3 use the marker // REQUIRES_SMT_BACKEND z3 —
the test runner skips them when Z3 is unavailable.
Timeout per function: default 2 seconds. Override locally:
#verify_timeout(10000)
#verify
fn complex_proof(x int) -> int
...
Cross-check verification (Z3 ↔ CVC5)
Cross-check is a CI-only soundness safety net: it re-runs every verification condition through two independent solver paths and fails the build if their definite answers disagree. It is the second line of defence after the soundness-regression suite (Plan 33.8) — the regression suite catches known bug classes, cross-check catches unknown ones.
The two paths are deliberately independent:
- Z3 — through the FFI backend.
- CVC5 — through a textual SMT-LIB v2 script fed to the
cvc5binary as a subprocess.
The textual path shares no code with the Z3-FFI translation, so it is also a second independent encoder. An encoding bug that silently dropped a formula on the Z3 side (the class of bug found in Plan 33.8) would be caught here even without a second solver.
Running it
# Build with the Z3 backend, install cvc5 on PATH (or point NOVA_CVC5
# at the binary), then:
NOVA_CROSSCHECK=1 nova test . --filter contracts
NOVA_CROSSCHECK=1 takes priority over NOVA_SMT_BACKEND. Normal
compilation (nova build / nova check) is unaffected — it keeps
using a single solver, so developer compile times do not grow.
If cvc5 is not found the run degrades gracefully to “Z3 only” with a
warning — cross-check simply does not happen, the build does not break.
What counts as a disagreement
Only a definite disagreement gates: one path says Proven (unsat),
the other says Disproved (sat). Any Unknown / timeout on either side
is normal (the solvers have different performance profiles) and is
not an error.
A disagreement is reported as compile error E2412 with the function,
the VC, both verdicts, the counterexample, and the SMT-LIB script for
manual reproduction. It is soundness-critical: one path produced a wrong
answer, so the verifier may have declared a false Proven.
CI gate
The contracts-crosscheck workflow runs the whole contracts corpus
under NOVA_CROSSCHECK=1 and requires 0 disagreements for merge.
NOVA_CROSSCHECK_LOG=<file> makes every disagreement append a line to
that file (the corpus is compiled process-per-file, so the file is the
cross-process aggregation point the gate checks).
Contract syntax grammar
contract-clause = requires-clause
| ensures-clause
| decreases-clause
requires-clause = 'requires' bool-expr
ensures-clause = 'ensures' bool-expr
decreases-clause = 'decreases' expr
fn-contracts = contract-clause*
loop-invariant = 'invariant' bool-expr
loop-decreases = 'decreases' expr
calc-block = 'calc' '{' calc-step+ '}'
calc-step = expr ';'
| ('==' | '<=' | '>=' | '<' | '>') expr ';'
reveal-stmt = 'reveal' ident
apply-stmt = 'apply' ident '(' expr-list ')'
assert-static = 'assert_static' bool-expr
assume-stmt = 'assume' bool-expr
quantifier-expr = 'forall' ident 'in' expr ':' bool-expr
| 'exists' ident 'in' expr ':' bool-expr
old-expr = 'old' '(' expr ')'
result-ref = 'result' // only in ensures
Attribute summary:
| Attribute | On | Meaning |
|---|---|---|
#verify | fn | Strict SMT: compile error if contracts not proven (was #must_verify pre-Plan 33.3) |
#unverified | fn | Skip SMT; contracts enforced at runtime in debug and release (unproven) |
#pure | fn | Explicit pure declaration (no effects); usable in contract expressions. Voluntary — purity is inferred automatically for effect-free bodies; needed only where inference can’t reach (extern fns) |
#nooverflow | fn | Add overflow proof obligations for every +/-/* on sized integers |
#trusted | fn / with binding | Accept contracts as axioms without proof |
#opaque | #pure fn | Hide body from SMT; require reveal to expose |
#fuel(n) | #opaque #pure fn | N-level recursive unrolling after reveal |
#verify_timeout(ms) | #verify fn | Override per-function SMT timeout |
| (no attribute) | fn with contracts | Soft mode: SMT tries, failure → W2401/W2402 warning + runtime check in debug and release |
Error reference
| Code | Message | Cause |
|---|---|---|
W2401 | contract not verified statically | SMT returned Unknown or timed out; falls back to runtime check |
W2402 | unverified: ... | Various: dead lemma, duplicate apply/reveal, reveal in non-verify context |
W2403 | opaque: ... | reveal for non-opaque fn, #fuel(0), dead #opaque (never revealed) |
E2401 | unsupported expression in contract | match, lambda, tuple literal, or other construct the SMT encoder cannot represent at all (calls — free fn or method — are always encodable; an effectful callee is instead a dedicated “requires it to be pure” compile error, not E2401) |
E2402 | contract violation | SMT disproved the contract (found counterexample) |
E2412 | cross-check disagreement | Z3 and CVC5 returned opposite definite verdicts for a VC (cross-check mode only) |
trust-introduced | warning | assume outside #trusted context |
Bootstrap limitations
| What does not work / is deferred | Plan |
|---|---|
#must_verify_module — strict mode for an entire module | D113 (Plan 33.3, V2) |
| SMT cache + incremental verification | D114 (V2) |
Parallel verification via rayon | D114 (V2) |
| Loop invariants with Z3 — full inductive reasoning | Plan 33.x V2 |
forall/exists in loop invariants | Plan 33.x V2 |
Effect-aware contracts (ensures Db.balance(...) == ...) | D24 / D120 (partial in V1) |
Recursive lemma bodies (structural induction) | Research / V3 |
| Non-linear arithmetic in contracts | Z3 can sometimes handle; no static guarantee |
| Floating-point reasoning | Not planned |
String predicates beyond len() and equality | Not planned for V1 |
#fuel(0) is a warning (W2403) — use omitting #fuel instead | By design |
Related documents
spec/decisions/09-tooling.md— D24 / D89 / D111 / D112 / D113 / D114 / D116 (contracts, SMT, test tooling)spec/decisions/04-effects.md— D120 (#pureviews + axioms), D115 (axiom binders)docs/plans/33.9-opaque-reveal-fuel.md—#opaque/reveal/#fuel(n)implementation (Plan 33.9)docs/plans/33.14-z3-cvc5-crosscheck.md— Z3 ↔ CVC5 cross-check implementation (Plan 33.14)nova_tests/contracts/— ~280 contract verification testsnova_tests/doc/f23_contracts_positive.nv— basic contracts doc-examplenova_tests/doc/f24_infer_contracts_positive.nv— inferred contracts doc-examplenova_tests/doc/f25_mutation_contracts_positive.nv— mutation contracts doc-examplenova_tests/expected_runtime/— runtime contract violation tests (contracts_*.nv)