Postconditions that quantify over a returned list¶
The gap¶
A trusted function's postcondition can relate its result to its arguments as long as the result is a scalar:
@clauz3.ensures(lambda cap, result: result <= cap)
@clauz3.trusted
def count_up_to(cap: int) -> int: ...
It cannot say anything about the elements of a returned list. The three claims that matter most in the shipped layers are all of that shape, and all of them are currently English:
@clauz3.ensures(lambda result: len(result) <= 100)
@clauz3.trusted
def db_query(table: str, where: dict[str, object] | None = None) -> list[UserRow]:
"""... only rows where all key=value constraints hold are returned."""
| Effect | The claim | Today |
|---|---|---|
db.read_rows / db_query |
every returned row satisfies where |
docstring |
find |
every returned path is under the walk root | docstring |
grep |
every returned line contains the pattern | docstring |
The consequence is specific and worth stating plainly. examples/email-from-db
proves addresses_from(UserRow, "email") — every address came from the
query's email column — and count_at_most(100). It does not prove that
the recipients consented. db.only_where({"consented": True}) constrains the
query the program issues, not the rows that come back; the step from "asked
for consenting users" to "these rows are consenting users" is the trusted
layer's audit, not a proof.
That is a defensible place to draw the line — it is what a trusted base is — but it is currently invisible: nothing in the guarantee an operator approves distinguishes a fact the prover established from a docstring it believed.
Why it does not work¶
Two separate reasons, either of which is enough:
all(... for x in result)is aGeneratorExp, which the expression evaluator refuses (unsupported ast node GeneratorExp). The list-comprehension form fails differently and no better:cannot resolve name all.- A returned
list[str]is not iterable in a proof at all. Only alist[Row]-returning trusted call can be iterated, via the symbolic iteration machinery.
So even a spelling that parsed would only reach the list[Row] case.
Sketch¶
The row case is the valuable one and the machinery is half there. A for-loop
over db_query(...) already introduces a quantified binder and evaluates the
body under it (FactInfo.quantifiers, _active_quantifiers). A postcondition
over rows wants the same binder introduced at the call, and its predicate
added to ctx.given rather than ctx.expected — the prover assumes a
postcondition, it does not check it.
Roughly:
- A spelling in the subset.
ensures(lambda where, result: result.all(lambda r: r.consented))reuses the relation-algebra vocabulary the contract layer already speaks, and avoids putting comprehensions into the expression evaluator. - Evaluate it at the call site against the materialized row list, under a
fresh binder, and conjoin the result into
ctx.givenguarded by the call's reachability condition — the same guard_materialize_trusted_returnalready applies to a scalarpost. - Decide what a
where-relating predicate may say.r.consented == where["consented"]needs dict subscripting of a literal dict argument in the expression subset;whereis already required to be a literal at the call site, so this may be a constant fold rather than a new sort.
Worth doing?¶
The pull is that it closes the gap between what an approval says and what
was proved, for exactly the query-then-act shape this project exists for. The
push-back is that a trusted layer's own body is unproven either way: an
ensures over rows is still a claim the trusted layer makes about itself, and
an unsound one makes every proof about its callers unsound. It moves the claim
from a docstring to a checked-at-runtime, read-by-the-prover contract, which is
a real improvement in kind, not in trust.