Generic Effect Specs¶
This document describes the current contract-spec prototype. It is the path away from domain-specific solver callbacks and toward reusable, auditable contract libraries.
Goal¶
Domain authors should be able to write contracts like:
Email = effect(send_email)
@contract
def only(addresses: list[str]) -> ContractSpec:
return Email.all(lambda e: e.addr in addresses)
without knowing about ctx.facts, z3 proxy objects, or the vendored
deal-solver internals.
The core idea is that every trusted function invocation creates a row in a finite relation. Contracts are relational constraints over those rows.
Trusted Functions Become Effect Facts¶
A trusted function is an audited side-effect boundary:
import clauz3
@clauz3.requires(lambda account, amount: amount >= 0)
@clauz3.trusted
def withdraw(account: str, amount: int) -> None:
...
When the prover sees a reachable call:
it records a symbolic fact like:
The trusted function body is not symbolically executed. Its preconditions are proved as assertions, then the call is recorded as a fact.
This is generic. The core does not know what a withdrawal, email, HTTP request, or file write means. It only records the trusted function name, signature fields, symbolic arguments, and reachability condition.
Declaring mock implementations¶
Use @clauz3.mock alongside @clauz3.trusted when a library implementation
simulates an effect:
import clauz3
@clauz3.mock
@clauz3.trusted
def send_email(addr: str, msg: str) -> None:
"""Example sender: no message is delivered."""
pass
The decorator is an identity operation; @clauz3.mock() is also supported.
It does not replace the function body, grant trusted status, suppress I/O, or
bypass proofs and approvals. Preconditions and effect contracts retain their
normal meaning. Agent-authored programs cannot apply this decorator.
Tool discovery and the workspace read the declaration from source without importing the library. Library selection and review distinguish declared mock effects, a mixture of mock and unmarked effects, and sources whose declarations cannot be read. Unmarked does not mean real or safe. Approval cards show mock declarations from the operator-authorized libraries, not from claims supplied by the agent; those declarations describe the selection rather than a complete analysis of the submitted program's calls.
A mock declaration is not enforced demo isolation. An incorrectly marked implementation, its imports, or its contract helpers can still perform real effects. Review the code as trusted code. The bundled email and bank examples use this marker for their mock implementations; general-purpose enforced demo mode is separate work.
Effect Relations¶
effect(*targets) creates a relation view over trusted facts. Name the
function itself — the import is then checked by Python, mypy and your editor's
rename:
from clauz3.spec import effect
from tools.filesystem.trusted.effects import append_file, write_file
WriteFile = effect(write_file) # one effect
WriteText = effect(write_file, append_file) # both text-writing effects
Any = effect() # every trusted call
A string names the same thing for a layer that cannot import its effects
(effect("write_file")), and is checked against the trusted layer when the
proof loads it.
An unresolvable relation is refused. A name that is not a trusted effect of the layer being proved is a configuration error, not an empty relation:
error: effect('db_read') in tools/db/trusted/contracts.py names 'db_read',
which is not a trusted effect. Known effects: ['read_rows']. (A name that
matches nothing would make every contract over this relation hold vacuously.
If it was a @deal.has marker, name the function that carries it.)
That refusal is the point. A contract over an empty relation is a conjunction
of no clauses, which is True, so a relation that quietly matched nothing
would make its guarantees hold however the program behaves.
Fields come from the signature. send_email(addr, msg) gives e.addr and
e.msg, and a predicate reading anything else is refused where the relation is
used, naming what is available. A relation over several effects offers the
fields they share, since the predicate is applied to every matching fact —
which is what makes a group like effect(write_file, append_file) safe to write.
effect() covers whatever a program calls, so no field is guaranteed and the
check falls back to the one the expression compiler does per fact.
Grouping belongs to the contract that depends on it. A relation could once
name a @deal.has marker instead — effect("read") matched whichever
functions carried the string read — which made the membership of a relation
something no contract stated: a later effect, or an unrelated layer installed
alongside, silently joined or left it, and the matched facts need not even have
the same fields. When several tools act on the same resource, declare the
events each one performs and state contracts over the events, as
stdlib:filesystem does: its only_read_under is a query over Read and
List events, so it covers read_file, grep and find alike.
Relation Primitives¶
The current relation API is intentionally small.
no_guarantees¶
no_guarantees() is the transparent null contract. It solves to True and
imposes no constraints:
from clauz3.spec import no_guarantees as core_no_guarantees
@contract
def no_guarantees() -> ContractSpec:
return core_no_guarantees()
This is semantically equivalent to omitting the guarantee, but it is useful in user-facing flows because it lets an agent explicitly say, "for this domain, I make no guarantees."
all¶
Every reachable row must satisfy a predicate:
The path condition is handled by the relation primitive. Authors do not write
if reachable then ... manually.
empty¶
No reachable row may exist:
This is used by emails.none().
distinct¶
No two reachable rows may have the same key:
This proves "never email the same person twice." Mutually exclusive branches are allowed to produce the same key because both rows cannot be reachable in the same execution.
sum¶
Sum a selected numeric field over reachable rows, then compare it:
Unreachable rows contribute zero.
count¶
Count reachable rows, then compare the count:
Aggregates over a for loop¶
A row emitted inside for row in db_query(...) stands for one call per loop
index, so it contributes the loop's length rather than 1. Two consequences are
worth knowing before writing a bound:
- An upper bound must cover the loop source's whole length postcondition. A
row-dependent guard such as
if row.consented:does not lower it. - A strictly positive count lower bound cannot follow from a loop whose
source may be empty. The lower bound
count() >= 0can still prove.
For counts and nonnegative constant-selector sums, both directions are conservative under the supported loop-body restrictions. See Counting and summing over a loop for the emitted term and a table of exact and approximate cases.
where¶
Filter a relation before applying another relation primitive:
This proves policies like "Bob is emailed at most twice." The filter is also guarded by reachability conditions, so facts in unreachable branches do not contribute to the filtered count.
shares_value¶
Require two filtered relation views to have at least one reachable pair with the same selected value:
bob = Email.where(lambda e: e.addr == "bob@example.com")
ann = Email.where(lambda e: e.addr == "ann@example.com")
bob.shares_value(ann, lambda e: e.msg)
This proves policies like "Bob and Ann receive the same email content." It is an existential join: at least one Bob row and at least one Ann row must be reachable with equal selected values.
Lambda Subset¶
Relation lambdas are parsed from source and compiled by clauz3.spec.
Supported today:
- constants:
bool,int,str - list literals and captured lists
- row attributes:
e.addr,w.amount - comparisons:
==,!=,<,<=,>,>=,in,not in - boolean operators:
and,or,not - numeric
+and- len(...)over supported row values such as stringsstr.startswith(prefix)andstr.endswith(suffix)over string row values, for path-prefix policies such ase.path.startswith(root)
A relation lambda is located by its source position, so it must be
identifiable uniquely. Two lambdas on one line are fine when they are
siblings, as in Email.where(lambda e: ...).all(lambda e: ...); a lambda
nested inside another lambda on the same line is refused because the inner
one's instructions lie inside both spans. Write a curried predicate factory
as a def instead:
# refused: the inner lambda cannot be told apart from the outer one
by_addr = lambda who: lambda e: e.addr == who
# fine
def by_addr(who: str):
return lambda e: e.addr == who
The lambda's source file must also be readable on disk. Telling two lambdas
on one line apart needs the column positions that -X no_debug_ranges strips,
so under that flag put each lambda on its own line; a lambda alone on its line
is located by line number and is unaffected.
Unsupported constructs fail closed with an UnsupportedError. Notably,
arbitrary function calls inside relation lambdas (other than len(...) and the
whitelisted string methods above), loops, mutation, and dynamic attribute
access are not supported in the spec layer yet. See
ClauZ3 Python Subset for the full current subset.
What Is Still Trusted¶
This prototype reduces the trust burden, but it does not eliminate it.
Trusted today:
- trusted roots such as
tools/email/trusted/ - the vendored and modified
deal-solvermachinery - the
clauz3.speccompiler and relation primitives - domain helper builder bodies marked with
@contract
The last item is the main remaining gap. A helper like this is easy to audit:
@contract
def max_spend(limit: int) -> ContractSpec:
return Withdraw.sum(lambda w: w.amount) <= limit
but it is still executed as Python. Future work should validate helper bodies by AST before executing or compile them without executing arbitrary Python. For the full boundary, which pieces execute, which are interpreted, and what each may reference, see Concepts → Trust boundary.
Why Not Hard-Code Email?¶
The effect relation is derived from trusted functions, not from domain names. Trusted functions and domain contracts live together under trusted roots.
Email:
Bank:
Both follow the same rule: the trusted function name creates the relation, and the function parameters create the row fields.
Relationship To Datalog And SQL¶
Most useful side-effect contracts are relational:
- allowlists are universal predicates over rows
- "never twice" is a uniqueness constraint
- budgets are aggregates
- read-only is absence of write rows
- table policies are set membership over database action rows
The current Python API is deliberately small, but it is close to a relational algebra. A later frontend could be more explicitly Datalog-like or SQL-like and compile to the same internal relation primitives.
Useful future features:
- mypy-level typing of a relation's fields:
effect(send_email)knows the parameters and their types at contract-build time, bute.addris still an attribute on an opaque row, so a type error in a predicate (e.addr <= 5) surfaces as a Z3 sort mismatch rather than a type-check failure. A generated per-effect row type would close that - richer string predicates (substring, regex) beyond the current prefix/suffix checks
exists,any_of, and grouped aggregates- ~~provenance facts to describe where an argument value came from~~ — the common case (value from a column of a trusted query result) is now addressed by symbolic iteration; see symbolic-iteration.md.
- contradiction, redundancy, and subsumption checks over policies — a first, mention-level completeness check now exists; see Coverage policies
- deterministic natural-language rendering for approval dialogs
- description logic / OWL renderings for contracts; see OWL / Description Logic Mapping
Runtime Role¶
Static proof answers whether the whole program satisfies its contract before any trusted side effect happens. That is important for transactional intent: the system can reject the entire program rather than discovering halfway through that the next effect is forbidden.
A runtime monitor can still be useful as defense in depth, but it should enforce the already-proved contract rather than replace the static proof step.