Action enforcement
The enforcement boundary is the sensitive action executor — not the agent, not a skill, not an MCP status field. A protected action must have no unguarded path: its trusted API takes both the proposed action and the receipt.
execute_purchase(order, yanez_receipt)
sequenceDiagram
participant A as Agent
participant E as Action executor
participant Y as Yanez Pulse
A->>E: order + receipt
E->>Y: GET /api/authz/public-keys (cached, refreshed once on an unknown kid)
E->>E: Verify signature, claims, terms, freshness
E->>Y: POST /api/authz/introspect (consume: true)
Y-->>E: valid: true, consumed_now: true
E->>E: execute_purchase(order)
The executor’s checklist
- Reconstruct the expected terms from
order: your own inputs, not the agent’s claims. - Verify the receipt.
- Transport and discovery. Fetch the key set from
/api/authz/public-keyson a base URL you configure, over HTTPS, without following redirects. Cache it, and on an unknownkidrefresh it at most once per 30 s before rejecting. - Key selection. Pin
alg == "EdDSA", select the key by the headerkid(never by algorithm), and skip any JWK entry with a missing or wrongkty,crv,alg, orkid. - Issuer.
issequals the exact string the Yanez operator publishes, taken from your own configuration. - Claim profile. All required claims are present;
yanez_decision == "approved";sub,jti, andyanez_agent_key_idare non-empty strings;yanez_termsis an object;iatandyanez_decided_atare equal integers not more than 60 s in the future;yanez_match_overlapis an integer>= 0;yanez_consent_not_afteris an integer when present. - Subject.
subequals the YID entitled to act on this account (expected_sub).
- Transport and discovery. Fetch the key set from
- Verify the approver’s own signature, and check every field inside it against this receipt. This is the half a JWT library cannot do for you, and skipping the field checks makes the signature check decorative: verifying, step by step.
- Compare terms.
yanez_termsequals the expected terms structurally, and equals the terms inside the signed message. No ignored fields, no wildcards. - Apply your freshness policy. Refuse when
now - yanez_decided_at > max_age. - Honor the declared consent bound. Refuse when
now > yanez_consent_not_after. - Apply your assurance floor. Refuse when
yanez_assurance_tieris below what the value at risk warrants. Alow-tier approval is genuine; whether it is enough is your decision, and nobody else’s. - Consume, for single-use actions, immediately before executing:
POST /api/authz/introspect {"artifact": ..., "consume": true, "consumer_token": ...}, and proceed only onconsumed_now: true. Write the token and an idempotency key derived fromjtidurably before the call.
Selecting a key by algorithm instead of by kid, and reading iss from the unverified
token and then “verifying” against it. The expected issuer is your own configuration;
the SDKs make it a mandatory constructor argument.
Using the SDK
Both SDKs package steps 2 through 8 as one call:
from yanez_authz import ReceiptVerifier
verifier = ReceiptVerifier(
base_url=base_url, # configured, never taken from the receipt
expected_issuer=expected_issuer, # configured, never taken from the receipt
)
# Steps 2-8: verify both signatures, compare terms, apply time and tier policy, consume.
receipt = verifier.authorize_action(
artifact,
expected_terms, # rebuilt from the order, not from the agent
max_age_seconds=900,
consume=True,
consumer_token=job.consumer_token, # yours, durable, reused on every retry
expected_sub=order.approver_yid, # the YID your records tie to this account
min_assurance_tier="high", # your floor for the value at risk
)
# Only reachable if authorize_action returned.
execute_purchase(order, idempotency_key=job.idempotency_key)
authorize_action raises ReceiptVerificationError (or its subclass
UserSignatureError, when the approver’s own signature is what failed),
ConsentPolicyError, AlreadyConsumedError, or ReservationHeldError when a check
fails, and TransportError when Yanez cannot be reached. On all of them except
ReservationHeldError, do not act — see
recovering a lost consume. The verifier refuses plain HTTP outside loopback,
does not follow redirects, and caches the key set for ten minutes. Both SDKs apply the
same checklist and reach the same verdicts on the shared conformance fixtures.
Bind the receipt to the account
A genuine receipt proves that some YID approved these terms. Naming the account
inside the terms does not change that: any user could approve terms that mention
someone else’s account. Always establish who approved, by passing expected_sub (the
YID your own records tie to the account) or by resolving receipt.sub against those
records before acting. expected_agent_key_id additionally pins which agent key asked.
What is never authorization
- An agent-generated boolean.
- Prose such as “the user approved”.
- Decoded but unverified JWT claims.
- A verified Yanez signature with the approver’s own signature left unchecked.
- An MCP tool result.
Replay and single use
Offline verification alone does not prevent replay: single-use actions consume, or
keep an equivalent relying-party jti ledger. If the external action fails after
consumption, the receipt stays spent, and a retry requires a new authorization. Yanez
cannot make consumption and a third-party side effect one atomic transaction.
Introspection responses
Signature, claim profile, and time bounds are checkable offline. Single use is not, and
that is the one job of POST /api/authz/introspect. It answers two separate questions:
valid says whether the receipt is genuine, and that answer does not change with time
while the deployment’s issuer and key configuration stay the same. reason and
consumed_now say whether it can be acted on now.
valid |
reason |
consumed_now |
Meaning and executor action |
|---|---|---|---|
false |
"bad_signature" |
— | Do not act Signature, issuer, required claims, or profile check failed. Not a receipt. |
true |
— | null |
Do not act Genuine, but consume was false, so nothing was reserved. Not enough on its own. |
true |
— | true |
Act now Genuine, and this call spent it. |
true |
"consent_expired" |
false |
Do not act Genuine, but yanez_consent_not_after has passed. Not consumed. Request a new approval. |
true |
"already_consumed" |
false |
Never act Genuine, but another holder already spent it. |
true |
"reservation_held" |
false |
Reconcile You already hold it, from an attempt whose response was lost. The action may already have happened: re-send or query downstream with your ORIGINAL idempotency key. Never re-approve. |
Every valid: true response also returns the decoded sub, jti, decided_at,
consent_not_after, terms, and the proof claims, so you read one shape whether or not
you can act. Those proof fields are the issuer’s report of the claims, not an
independent check: verifying the user’s signature is local work that no round trip can
do for you.
Consumption is permanent and deployment-wide: a spent jti never re-arms, because a
receipt that verifies forever must stay spent forever.
Consumption needs no credentials, so anyone who holds a receipt can spend it and deny the legitimate executor the action. Never log a receipt, never put one in a URL or query string, and send it only over TLS.
Act only when the consume response says consumed_now: true. When that response is lost
to a timeout or a reset, retry with the same consumer_token: reservation_held
means your own earlier attempt won, and already_consumed means someone else did. That
distinction is the whole reason the token is caller-supplied, and it is why the server
never generates one — a server-minted token would be lost along with the response it
travelled in.
On reservation_held, reconcile the downstream action with your original idempotency
key. Requesting a new approval there mints a second jti for an action that may already
have succeeded, and the duplicate is invisible to every consumption check.