Contract approval-binding/v1
Contract identifier: https://probityai.github.io/agent-evidence-vectors/contract/approval-binding/v1
Corpus: corpora/vectors-approval-binding. Carried in release v0.37.0.
Approval-binding vectors
Cases for approval-binding/v1: a record of one approval presented for one
use, the payload bytes of the use, and the decision a verifier must reach. The
expected decision and reason for every case are in MANIFEST.json.
An approval can be well formed, correctly signed and unexpired and still be the wrong approval for the use in front of the verifier. Every denied case here changes exactly one binding of an otherwise conformant record, so a verifier that checks only the approval's own signature, payload digest and expiry allows most of them. The corpus tests show which ones.
The binding axes and where they come from
Each axis is a defect class that public projects fixed or discussed in their own trackers in October 2026. The cases are written from the class, not copied from any project.
| Axis | Case that refuses it | Public fix or proposal |
|---|---|---|
| One identifier, one approval | approval-id-reused-different-content |
microsoft/agent-framework#9535: filtering one approval response could expose another with the same identifier |
| Used once | approval-already-consumed |
approval reuse, the same pull request |
| Signing key | approval-key-not-approvers |
MoltyCel/moltrust-api#887: ownership and key-binding checks before signing; an authorizer key id that differs from the trusted key's id |
| Approval asserted by the model | approval-asserted-in-tool-arguments |
google/adk-python#7655: a delete tool takes confirm_deletion=True from the model's own arguments as its confirmation, with no framework confirmation step |
| User, not client | same-client-other-user |
modelcontextprotocol/typescript-sdk#2782: task ownership keyed by the OAuth client identifier |
| Event and phase | event-differs, phase-differs |
modelcontextprotocol/experimental-ext-interceptors#46: confirmation bound to event, phase, target and payload |
| Payload after mutation | payload-mutated-after-approval, target-differs, tool-differs |
the same proposal: rechecked after a mutation |
| Validity window | used-at-expiry, used-before-issued |
|
| The use cites the approval checked | approval-use-cites-predecessor, approval-use-cites-no-approval |
the review of the renewal cases on #292 by AAkhtanin: a use naming another approval, or none, was judged against the one presented |
| Policy served at decision time | release-first-served-after-decision, release-retired-beyond-grace |
JanssenProject/jans#15447: release history and a grace period for late records |
| A failed lookup is not a match | release-lookup-unavailable |
the same pull request |
| Renewal by replacement | approval-renewed-same-bounds, approval-reused-after-renewal, approval-renewal-transition-not-accepted, approval-renewal-decision-not-distinct, approval-renewal-predecessor-decision-not-cited, approval-renewal-bounds-differ, approval-renewal-accepted-after-use, approval-renewal-predecessor-ambiguous |
the approval-renewal thread in aaif/wg-observability-and-traceability#66: the cases were asked for by narko4u and the sequence comes from AAkhtanin |
| A new grant does not reopen a closed effect | approval-renewal-reopens-consumed-effect, approval-renewal-reopens-uncertain-effect |
the same thread |
The controls (allow-*) keep a verifier from denying everything: another
approval under a different identifier, the same approval presented twice with
identical content, a use one second before expiry, a release first served at
the decision instant, a release last served exactly the grace period
before it, a replacement B that cites its predecessor's decision
(approval-renewed-same-bounds), and the predecessor presented before the
replacement was accepted (allow-predecessor-before-replacement).
The checks, in order
A verifier names the first check that fails.
- The record carries
contractId(contract_id_missing). contractIdishttps://probityai.github.io/agent-evidence-vectors/contract/approval-binding/v1(contract_id_unknown).- The use's
approvalIdis the approval'sapprovalId(approval_not_cited). A use that names another approval, or none, is not judged against the approval presented, in a renewal or not. - No other entry in
presentedApprovalshas the approval'sapprovalIdand differs from the approval in any member (approval_id_ambiguous). Entries are compared as parsed JSON values over the whole object, including members the contract does not otherwise read, so a duplicate whose only difference is an extra annotation member is ambiguous. The same approval presented twice, with equal values, is not ambiguous. approvalIdis not inconsumedApprovalIds(approval_replayed).- No replacement accepted at or before
usedAthas taken the approval's place (approval_superseded). - A use that claims
authorityForm: renewedhas a transition accepted at or beforeusedAtwhose successor is the approval (renewal_transition_missing). A transition accepted after the use does not establish the renewal. - That transition binds the successor to its predecessor
(
renewal_binding_invalid). The presented records under the predecessor's identifier must agree, so two that differ refuse whatever their order. trustedKeys[approver]is the approval'skeyId(key_not_bound).- The use's
principalis the approval'sprincipal(principal_mismatch). A matchingclientIddoes not stand in for the user. - The use's
eventandphaseare the approved ones (event_phase_mismatch). - The used
tool,targetand payload bytes are the approved ones (payload_mismatch). usedAtis at or afterissuedAtand beforeexpiresAt(approval_outside_validity).- The effect named by the use's
effectKeyis notCONSUMEDorUNCERTAIN_CLOSEineffectRegistry(effect_already_closed). policyRelease.lookupisok(comparison_unavailable).- The release's
firstServedAtis at or beforedecisionTime, and itslastServedAtplusgraceSecondsis at or after it (release_not_served_at_decision).
A record that passes every check is allow with reason bound.
comparison_unavailable is a deny rather than an indeterminate outcome. The
record states that the lookup failed, so the contract is not silent about the
input; what it forbids is reading "not checked" as "matched".
Renewal by replacement
Reusing an approval cites the existing approval A. Renewal by replacement cites the new approval B and an accepted transition from A to B. Equal bounds, the same recorded decision and the same logical effect are three different identities, so the record carries each apart:
approval.decisionIdis the recorded decision that authorised that approval.transitionslists the replacements the issuer recorded. An acceptedexclusive-replacementnames the predecessor approval and decision, the successor approval and decision, andacceptedAt. The bounds a renewal holds equal areevent,payloadSha256,phase,principal,targetandtool; the validity window is B's own. A transition counts fromacceptedAton, for the predecessor it replaces and for the successor it establishes alike. Exclusive replacement is this suite's policy, not a claim that every renewal policy forbids overlap.use.authorityForm: renewedis a claim about a transition, and is never evidence that one was accepted.use.effectKeyandeffectRegistrycarry the logical effect. A new grant does not reopen an effect that closed asCONSUMEDorUNCERTAIN_CLOSE.
approval-reused-after-renewal presents A after the A to B replacement was
accepted and before any execution under B. A is unexpired and unconsumed and the
effect is reserved, not executed, so only supersession can refuse it. A reader
that tests A only after B has run can pass this through its consumed set and
miss a broken supersession. The control allow-predecessor-before-replacement
presents A before the replacement was accepted and is allowed, so the case does
not reward refusing every predecessor.
The reader grades the approval decision: deny A, then allow B. That an executor started nothing and consumed no effect reservation on the refusal, and that exactly one execution followed under B, are runtime observations an approval reader cannot make; a runtime counterpart records them separately.
Running it
pip install agent-evidence-vectors
agent-evidence-vectors --corpus vectors-approval-binding
A verifier under test takes record.json --json, prints one JSON object
carrying decision and reason, and exits 0 for allow and 1 for deny:
agent-evidence-vectors --corpus vectors-approval-binding --verifier './verifier'
python3 vectors-approval-binding/check_vectors.py --verifier './verifier'
aee-verify vectors-approval-binding judges the same cases with the Go reader.
python3 scripts/approval-binding-rails-test.py holds the packaged reader,
check_vectors.py and the Go reader to one decision and reason on every member
and on mutated copies of every member.
Regenerate the cases with python3 vectors-approval-binding/gen_vectors.py.
Passing these cases does not establish that a signature is valid, that the trusted key set is current, or that the consumed set is complete; those come from the signing, key-distribution and replay-store layers the record cites.