Approval policies¶
How a policy admin lets the approval service decide a request without waiting for a human, and why that is safe — including for a block list.
Status¶
Implemented. clauz3 approval-service --policy <file.json> loads a policy; each
incoming request is evaluated against it before a human is asked. A matching
rule resolves the request immediately as auto_approved (with a receipt) or
auto_rejected; with no match the request stays pending exactly as before.
Evaluation lives in clauz3.approval_policy; the service wires it into
POST /requests.
This is the "Policy Interaction" step sketched in user-approval-dialog.md: hard-deny, auto-pass, else ask.
Three roles, three trust levels¶
| Role | Authors | Concern |
|---|---|---|
| Trusted Layer Engineer (TLE) | trusted-root effects, contracts, and policy.py coverage policies |
what the agent can do and should state |
| Policy admin | the approval policy below | what the user will accept without being asked |
| User | per-request decisions in the UI | this specific request |
The policy admin is usually the user wearing a "config" hat rather than a "run" hat: they pre-decide the boring cases so the live dialog is reserved for the ones that genuinely need a human. The policy is held by the approval service — not the trusted root and not the agent-invoked CLI — which keeps the decision outside the agent-controlled execution path, the core invariant of the approval design.
This is distinct from the TLE's coverage policy (coverage-policies.md). Coverage says what an agent should state about a domain; an approval policy says what the service will auto-accept or auto-reject.
Decisions are made by entailment¶
The key move: a rule's clauses are contract expressions, and the service
decides whether the program entails them — not whether the program's text
mentions them. To test a clause, the service conjoins it onto the target as an
extra @clauz3.guarantee(...) and re-proves, exactly as the required coverage
tier does (coverage-policies.md, "How
required is enforced"). A clause holds whenever it is logically implied by
what the program proves.
Entailment is what makes both directions sound:
- A program that proves
none()(sends no mail) entailsat_most(10)for free; a program that sends one literal to an allow-listed address entailsonly([...]),unique_recipients(), andat_most(10)without stating any of them. Text matching would miss all of these. - A block-list clause is an avoidance obligation. "Reject unless you have
proven you avoid the CEO" rejects any program that cannot discharge it —
including one that reaches a data-dependent recipient under
no_guarantees(). The earlier text-matching prototype could only catch a program that openly proved it emails a blocked literal; entailment closes that hole.
Policy file¶
A JSON file with an imports map (aliases the clause expressions resolve
through, pointing at trusted contract modules) and an ordered list of rules:
{
"version": 1,
"imports": {
"pol": "tools.email.trusted.contracts"
},
"rules": [
{
"name": "deny-blocked-recipients",
"decision": "auto_rejected",
"reason": "the program cannot prove it never emails a blocked address",
"unless_proven": [
"pol.recipient_at_most('ceo@example.com', 0)",
"pol.recipient_at_most('press@example.com', 0)"
]
},
{
"name": "auto-pass-internal",
"decision": "auto_approved",
"reason": "bounded, unique, internal-only email",
"when_proven": [
"pol.only(['bob@example.com', 'ann@example.com'])",
"pol.unique_recipients()",
"pol.at_most(10)"
]
}
]
}
The clause vocabulary (only, unique_recipients, at_most,
recipient_at_most) is just the trusted layer's contracts; the policy engine
has no email-, bank-, or db-specific knowledge, matching the rest of clauz3.
Clause expressions are syntax-checked at load time; their references are
resolved when a request is proved.
Decision semantics¶
The two decisions are duals over one primitive — "does the program entail every clause?":
| Decision | Clause key | Fires when |
|---|---|---|
auto_approved |
when_proven |
the program entails all clauses |
auto_rejected |
unless_proven |
the program does not entail every clause |
This is why an allow-list alone is unsafe and the policy admin must make the
auto-pass conjunction complete. only([...]) permits a single whitelisted
address emailed ten thousand times; adding unique_recipients() and
at_most(10) to the same when_proven list is what bounds it. If the program
did not prove the count and uniqueness contracts, the rule does not fire and the
request falls through to a human. The prover, not the policy engine, closes the
hole.
Precedence and fallbacks¶
Deny wins. Every auto_rejected rule is evaluated before any auto_approved
rule, so an allow-list can never override a block-list. The first matching rule
of the winning kind supplies the decision and its reason.
The fallback is always ask the human — never a silent pass. A request whose
clauses can't be evaluated (an unresolvable expression, a prover error) skips
that rule rather than guessing, and a program matching no rule is sent to the
UI. Auto-decisions are recorded on the request (auto_decision: rule + reason)
and shown on the detail page, so a pass is still auditable.
Trying it¶
The fastest loop is clauz3 policy-check, which reports the decision a policy
would make for a program without starting a service or executing anything — the
policy admin's authoring tool. The examples/email Justfile exercises all three
outcomes against approval-policy.json:
cd examples/email
clauz3 policy-check --policy approval-policy.json \
--trusted-root tools/email/trusted --import-root . \
--expect auto_approved cases/only_bob_pass.py
clauz3 policy-check --policy approval-policy.json \
--trusted-root tools/email/trusted --import-root . \
--expect auto_rejected cases/policy_reject.py # reaches ceo@example.com
clauz3 policy-check --policy approval-policy.json \
--trusted-root tools/email/trusted --import-root . \
--expect ask cases/policy_ask.py # not blocked, not allow-listed
just policy runs these. In the live flow the same decision is reached by
starting clauz3 approval-service --policy approval-policy.json and pointing
clauz3 run at it: an auto-approved program gets an auto-… receipt and never
appears as a pending request, an auto-rejected one is denied, and anything in
between is sent to the user.
Limitations and future work¶
- The service re-proves. Each rule is one prover call against the program using the trusted/import roots from the request. Those roots must be reachable from where the service runs (true for the localhost flow). Re-proving is serialized with a lock because the prover mutates global import state, and it reloads trusted modules per call. A shared prover context and clause-set caching are future work.
- Entailment is per-program, single-request. A clause like
at_most(10)bounds one program; the service keeps no ledger across requests, so it cannot enforce "at most 10 emails to bob per day across many runs." Cross-request or cumulative limits need stateful aggregation that does not exist yet. - Scalar matching is only as rich as the contracts. A block-list entry is
expressed with
recipient_at_most(addr, 0); there is no first-class "avoid this set" contract, so each blocked address is one clause. Richer avoidance and set predicates are trusted-layer work, not policy-engine work. - Policy provenance. Only an explicit
--policy <file>is supported, loaded once at startup. A default location (e.g..clauz3/approval-policy.json), hot reload, and signing — the policy is what auto-issues receipts without a human, so its integrity matters — are future work. - Inherits the receipt-enforcement gap. An auto-approval issues an
auto-…receipt, but trusted functions do not yet verify receipts against bound arguments at execution time; auto-approval is exactly as strong as the proof, no more.