Formal Verification of Domain Invariants
Overview
Morph generates SMT-LIB2 verification checks from the ConditionExpr / ValueExpr AST. These are solved by the Z3 CLI, providing exhaustive proofs that complement the probabilistic fast-check property tests.
ConditionExpr AST
├── compileTestCondition → JavaScript (fast-check, probabilistic)
└── compileSmtCondition → SMT-LIB2 (Z3 solver, exhaustive)
Same AST, different compilation backend.
Architecture
The verification pipeline lives in contexts/generation/targets/verification/generator/src/:
| File | Role |
|---|---|
smt-compiler.ts |
Compiles ConditionExpr / ValueExpr to SMT-LIB2 strings |
declarations.ts |
Generates declare-const and sort declarations |
goals.ts |
Produces the three verification goals |
runner-gen.ts |
Generates the Z3 runner script |
generate.ts |
Main generator entry point |
Generated output per example project:
tests/verification/src/
checks/
consistency.smt2
precondition-satisfiability.smt2
preservation.smt2
verify.ts
The runner shells out to Z3 with a 30-second timeout:
const result = await $`z3 -T:30 ${check.file}`.quiet();
This sidesteps a Bun WASM compatibility issue with z3-solver (the npm package works in Node but crashes in Bun due to pthread/Worker assertion failures).
Verification Goals
1. Consistency
Can all entity invariants hold simultaneously?
Asserts all invariants for an entity and checks sat. If unsat, the invariants contradict each other — no valid entity state exists.
2. Precondition satisfiability
Can each operation actually be invoked?
Asserts all preconditions (pre invariants) for an operation and checks sat. If unsat, the preconditions are contradictory — the operation is dead code in the schema.
3. Invariant preservation
Does executing an operation preserve entity invariants?
Asserts preconditions ∧ postconditions ∧ ¬invariant and checks unsat. If unsat, the invariant is preserved (no counterexample exists). If sat, Z3 produces a concrete witness showing how the operation can violate the invariant.
Variable Auto-Declaration
The SmtCollector tracks variables encountered during compilation and auto-declares them:
| Source | SMT name pattern | Example |
|---|---|---|
| Context fields | ctx_X_Y |
ctx_currentUser_role |
| Input fields | input_X |
input_userId |
| Entity fields | entity_X |
todo_completed |
| String literals | |str_X| |
|str_admin| |
Types map to SMT sorts: integer to Int, float to Real, boolean to Bool, everything else to StringId (uninterpreted sort). Unions map to Int with range constraints.
Collection Bounds
Collections are bounded to 5 elements (COLLECTION_BOUND = 5). For each array field, the compiler declares a _len variable plus _0 through _4 element variables. This keeps quantifiers bounded and the fragment decidable.
SMT Fragment
Current expressiveness
| Category | Nodes | SMT-LIB equivalent |
|---|---|---|
| Comparisons | equals, notEquals, greaterThan, lessThan, greaterThanOrEqual, lessThanOrEqual |
=, distinct, >, <, >=, <= |
| Boolean | and, or, not, implies |
and, or, not, => |
| Quantifiers | forAll, exists |
bounded — desugar to finite conjunction/disjunction |
| Collection | contains, count |
member / seq.len |
| Values | field, literal, variable, call |
constants, values, uninterpreted functions |
Named fragment: QF_UFLIA with bounded quantifier macros
- QF — quantifier-free (our
forAll/existsare bounded over finite collections, so they desugar to QF) - UF — uninterpreted functions (
callexpressions: Z3 knowsf(a) = f(b)ifa = b, nothing else) - LIA — linear integer arithmetic (comparisons and equality over integers)
This fragment is decidable — Z3 is guaranteed to terminate with a definitive answer.
On quantifiers
The forAll and exists nodes look like logical quantifiers but function as collection predicates — they iterate over finite, known collections. They desugar to:
forAll(x in items: P(x)) → P(items[0]) ∧ P(items[1]) ∧ ... ∧ P(items[n])
exists(x in items: P(x)) → P(items[0]) ∨ P(items[1]) ∨ ... ∨ P(items[n])
This keeps the fragment quantifier-free (decidable). Unbounded quantifiers (∀x ∈ ℤ) would push into semi-decidable territory — avoid introducing those.
Gap: missing ValueExpr kinds
Two ValueExpr kinds are not yet implemented:
| Node | Structure | SMT-LIB |
|---|---|---|
arithmetic |
{ op: "add" | "sub" | "mul" | "mod", left, right } |
+, -, *, mod |
conditional |
{ condition, then, else } |
ite |
The “linear” constraint — mul must have at least one constant operand — is enforced by validation, not by the grammar.
Expression expansion guidelines
When proposing a new expression type, the review question is: does this stay in QF_UFLIA (+ bounded quantifiers)?
- Equality, ordering, linear arithmetic, uninterpreted functions, boolean logic: yes, freely add.
- Reals, difference logic, datatypes, strings: yes, each adds a decidable theory.
- Nonlinear integer arithmetic (
a * bwith two variables): no — semi-decidable. - Unbounded quantifiers (
∀x ∈ ℤ): no — semi-decidable. - General recursion or arbitrary computation: no — undecidable.
Adjacent fragments
| Fragment | Adds | Use case | Performance |
|---|---|---|---|
| QF_UFLRA | real arithmetic | money, percentages, price * 0.9 |
faster than LIA (no divisibility reasoning) |
| QF_IDL | integer difference logic (x - y ≤ c only) |
temporal invariants (createdAt < updatedAt) | very fast |
| QF_UFDT | algebraic datatypes | enum/status exhaustiveness checking | fast |
| QF_SLIA | string operations | length, startsWith, contains on string fields |
noticeably slower |
| QF_NIA | nonlinear integer arithmetic | a * b where both are variables |
semi-decidable — avoid |
These compose freely via Nelson-Oppen theory combination. Z3 coordinates automatically.