Symbolic Iteration¶
clauz3 can now reason about for-loops over trusted query returns and prove
column-binding contracts. See
superpowers/specs/2026-05-25-symbolic-iteration-design.md
for the original design rationale.
The agent-facing surface¶
Trusted functions return typed lists of clauz3.Row subclasses:
import clauz3
import deal
class UserRow(clauz3.Row):
name: str
email: str
consented: bool
role: str
@deal.has("db_read")
@deal.post(lambda result: len(result) <= 100)
def db_query(table: str, where: dict[str, object]) -> list[UserRow]: ...
Agents iterate with normal for-loops:
Fixed-size loops over literal lists/tuples or range(N) with a literal integer
are unrolled instead of quantified:
for _ in range(5):
withdraw("checking", 10)
for addr in ["bob@example.com", "ann@example.com"]:
send_email(addr, "hi")
Unrolled iterations emit ordinary concrete facts, so existing aggregates such
as effect("withdraw").sum(lambda w: w.amount) <= limit work without the
quantified-aggregate limitations below.
The contract surface¶
Contracts can refer to columns:
from clauz3.spec import contract, effect, ContractSpec
@contract
def addresses_from(schema: type, field: str) -> ContractSpec:
column = getattr(schema, field)
return effect("send_email").all(lambda e: e.addr == column)
Agent usage:
@clauz3.guarantee(emails.addresses_from(UserRow, "email"))
def main() -> None:
for row in db_query("users", where={"consented": True}):
send_email(row.email, "hi")
The prover verifies that every send_email fact's addr argument is
symbolically the email field of a row from a UserRow query — not a
literal address, not a different column.
Worked example¶
See examples/email-from-db/ for a complete
end-to-end example with multiple cases covering: headline, literal-address fail,
wrong-column fail, mixed-source fail, count bounds.
See examples/bank/ for fixed-bound loop examples that
prove or reject total withdrawal limits over range(5).
v1 limitations¶
(see the spec for full list)
- For-loop bodies cannot use
break,continue,return. range(...)loops are unrolled only forrange(N)whereNis a literal integer. Symbolic bounds,enumerate,zip, and dict iteration are not supported in v1.- No state accumulators across loop iterations.
- Row field types are
str,int,boolonly. wherearguments must be literal dicts at the call site.sum(lambda r: r.numeric_field)over a quantified fact is unsupported. See docs/todos/quantified-aggregates.md.shares_valueacross quantified relations is unsupported. See docs/todos/quantified-shares-value.md.
How it works (briefly)¶
The for-loop handler has two modes. For literal lists/tuples and literal
range(N), it unrolls the body once per concrete item and emits ordinary facts.
For trusted query results, it binds the loop variable to query_result.at(i)
for a fresh symbolic index i, runs the body once symbolically, and snapshots a
Quantifier frame into every emitted fact. The relation primitives in
clauz3.spec wrap their Z3 body in ForAll(...) over fact quantifiers; when no
quantifiers are present, the wrapping short-circuits and the relation behaves as
before.
Column-reference equality (e.addr == UserRow.email) is a structural check
on the symbolic expression tree: the prover walks the Z3 expression to verify
it has the shape <field selector>(array_select(<query result>, ?)) with the
expected schema and field.