--- title: User-signed approvals description: Receipts now carry the approver's own signature. What changed in the schema, and how to verify both signatures. --- # User-signed approvals A receipt used to carry one signature. Yanez signed it, and that signature said *Yanez saw this approval*. Everything you believed about the approval, you believed because Yanez said so. A receipt now carries **two**. The second is made by the approver's own key, on their own device, over the complete decision they made — the request id, the terms verbatim, the assurance tier their scan reached, and the deadline they agreed to. Verifying it tells you something Yanez cannot tell you: that the holder of that key approved *these exact terms*. This page covers what changed in the schema, and the steps to verify both signatures.
This release is breaking

The five proof claims are required. A receipt minted before this change fails verification rather than being reported as an approval nobody signed. consume also gains a required consumer_token. The Python SDK moves to 0.1.0b5 and the TypeScript SDK to 0.1.0-beta.6; see Upgrading.

## What changed in the schema ### Receipts gain five claims | Claim | Value | |---|---| | `yanez_assurance_tier` | `low`, `medium`, or `high` — the tier the approver's scan reached | | `yanez_user_public_key` | `0x` + 48-byte compressed G1 point, hex | | `yanez_user_signature` | `0x` + 96-byte compressed G2 point, hex | | `yanez_signed_message` | base64url of the exact bytes the approver signed | | `yanez_user_sig_alg` | `BLS12-381-G2-basic` | `yanez_terms` stays, and it now has a companion. The terms inside `yanez_signed_message` duplicate it **by design**: one is Yanez's assertion of what was approved, the other is the user's. Verification checks that the two agree, and disagreement is fatal. ### Terms gain a version and lose `display` `terms` is now a versioned profile. The server validates it on create and rejects anything outside it. | Change | Before | Now | |---|---|---| | `schema_version` | absent | required integer, exactly `1` | | `amount.display` | required string | **removed** — the app formats from `minor_units` and the currency's own exponent | | `amount.minor_units` | up to 2^63-1 | integer, `0` to 2^53-1 | | `currency` | any non-blank string | ISO 4217 code from the server's allowlist | | Numbers anywhere in terms | any JSON number | integers only, within the same bound | `display` is gone because two fields describing one amount can disagree, and the one the human read was the one that could lie. `¥18,000` and `$180.00` are both `18000` minor units; the app knows which currency has a minor unit and formats accordingly. The 2^53-1 bound is the largest integer a double round-trips exactly, so a JavaScript verifier and a Python one cannot disagree about the value they are comparing. See [Terms](terms.md) for the full field rules. ### The pending list gains two arrays `/list` returns `{requests, unavailable_requests, registered_tiers}`. Requests whose stored terms predate the profile move to `unavailable_requests`, carry no terms at all, and cannot be signed. One historical row can no longer break decoding for the valid rows beside it. ### Consuming a receipt requires a token `consume` now takes a `consumer_token`: your own opaque, durable string identifying the attempt. Reuse the same one when retrying after a lost response — that is how the server tells your earlier attempt from another holder's. The server never generates one, because a server-minted token would be lost with the response it travelled in. This adds a third consume outcome. See [Recovering a lost consume](#recovering-a-lost-consume). ## The signed message `yanez_signed_message` base64url-decodes to a JSON object like this: ```json { "action": "agent_authorizations.decision", "assurance_tier": "high", "authorization_request_id": "azr_c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0", "consent_not_after": null, "decision": "approve", "issuer": "https://yid.yanez.ai", "signed_at": 1767225596, "terms": { "...verbatim, exactly as the app received them..." }, "version": 1, "yid": "a1b2c3..." } ``` | Field | Meaning | |---|---| | `version` | Envelope version, `1`. Distinct from `terms.schema_version`; the two move independently | | `issuer` | The Yanez issuer this decision was made for. Must equal your configured issuer | | `action` | Fixed discriminator. Stops a signature minted in one ceremony being replayed into another | | `assurance_tier` | The tier the scan reached | | `authorization_request_id` | Binds the signature to one request | | `decision` | `approve` or `reject` — **a rejection is signed too** | | `consent_not_after` | Integer epoch seconds or `null`. The user's own bound on how long their consent may be acted on | | `signed_at` | Epoch seconds when the device produced the signature | | `terms` | The request's terms, byte-for-byte as the app received them | | `yid` | The signing identity | **Verify the bytes, never a re-encode.** The signature covers the exact bytes in `yanez_signed_message`. Parse them to read the fields, but check the signature against the bytes you decoded. Re-serializing the parsed object produces a different byte string, and it will not verify. Sorted keys are a producer convention, not a rule. ## Verifying, step by step Seven steps. The SDKs do 1 through 5 inside `verify`, and 7 when you pass `consume`. Step 6 is the one nobody can do for you: only your service knows which account this action belongs to. 1. **Verify the Yanez signature.** Fetch the JWKS, pin `alg` to `EdDSA`, select the key by `kid` — never by the token's own algorithm header — and check `iss` against your configured issuer. 2. **Apply your freshness policy** to `yanez_decided_at`, and check `yanez_consent_not_after` if present. 3. **Check `yanez_assurance_tier`** against your own floor for the value at risk. 4. **Verify the user's signature.** base64url-decode `yanez_signed_message` and verify `yanez_user_signature` over those exact bytes with `yanez_user_public_key`, using the parameters below. 5. **Check what was signed.** Parse the decoded message and check *every* one of these. Skipping any makes step 4 decorative: - `decision` is `approve`. A rejection carries a signature that passes step 4 perfectly. - `version` is supported, and `issuer` equals both your configured issuer and `iss`. - `action` is `agent_authorizations.decision`. - `authorization_request_id` equals the receipt's `jti`. - `yid` equals the receipt's `sub`. - `assurance_tier` equals `yanez_assurance_tier`. - `terms` matches `yanez_terms` structurally (see [Comparing terms](#comparing-terms)). - `consent_not_after` matches the receipt's bound; an absent claim means `null`. - `signed_at` is consistent with `yanez_decided_at` within an ingestion allowance. Bound the **difference** between them — never either one against today's clock, or every historical verification fails, which is exactly when a receipt matters most. 6. **Bind it to your own context.** Confirm the terms describe the action you are about to perform, and that `sub` is the account you mean. Pass `expected_sub` and `expected_agent_key_id`. A genuine receipt says *some* identity approved *some* terms; two people can approve identical terms. 7. **Consume it** atomically before acting, if the action is single-use. ### Cryptographic parameters | Parameter | Value | |---|---| | Curve | BLS12-381 | | Variant | Minimal pubkey size: public keys in G1, signatures in G2 | | Public key | 48 bytes, compressed G1 | | Signature | 96 bytes, compressed G2 | | Scheme | **Basic**. No message augmentation, no proof of possession | | DST | `BLS_SIG_BLS12381G2_XMD:SHA-256_SSWU_RO_NUL_` | The DST and the scheme go together. A verifier built on the augmented or proof-of-possession variant rejects every genuine signature, and one built on a hand-typed DST may accept under parameters nobody else uses. The `_NUL_` suffix is what marks the basic scheme. ### Comparing terms Deep equality, with three rules that ordinary helpers get wrong: - **A boolean is never a number.** `{"n": true}` must not match `{"n": 1}`. - **Numbers compare by value**, so `-0` equals `0`. Node's `isDeepStrictEqual` separates them and Python's `==` does not — two verifiers reading the same bytes would split. Both SDKs use `termsEqual` / `terms_equal` instead; use those rather than a stock helper. - **Array order is significant.** A reordered `details` array is different terms. Duplicate object keys are rejected when the signed message is parsed. `JSON.parse` and `json.loads` both silently keep the last one, so a message naming `decision` twice could be read two ways by two verifiers, each believing it agreed with the other. ## Verifying with the SDK Both SDKs check both signatures inside `verify`. Nothing extra to call. ```python from yanez_authz import ReceiptVerifier, ConsentPolicyError, UserSignatureError verifier = ReceiptVerifier("https://yid.yanez.ai", expected_issuer="https://yid.yanez.ai") try: receipt = verifier.verify( artifact, expected_terms=terms, max_age_seconds=900, expected_sub=account.yid, # step 6 min_assurance_tier="high", # step 3 ) except UserSignatureError as e: ... # the approver did not sign this. Never execute. except ConsentPolicyError as e: ... # genuine, but stale, expired, or below your floor. receipt.assurance_tier # "high", from the bytes the user signed receipt.signed_at # when their device signed receipt.user_proof.envelope # the full decoded message ``` ```typescript import { ReceiptVerifier, UserSignatureError } from "@yanez.ai/agent-authorization"; const verifier = new ReceiptVerifier("https://yid.yanez.ai", "https://yid.yanez.ai"); const receipt = await verifier.verify(artifact, terms, 900, { expectedSub: account.yid, minAssuranceTier: "high", }); receipt.assuranceTier; receipt.userProof.envelope; ``` `min_assurance_tier` is a **policy** gate, so falling short raises `ConsentPolicyError`, not a verification error. The receipt is genuine; it is just not strong enough for what you were about to do. Treating those two as one error class means an operator reading your logs cannot tell a forgery attempt from a user whose scan was mediocre. ## Verifying without the SDK If you already have a JWT library you trust, use it for step 1 and call the proof helper for steps 4 and 5. It takes a plain claims mapping and needs no client, no network, and no configuration beyond your issuer. ```python import jwt from yanez_authz import verify_user_proof, UserProofError claims = jwt.decode(artifact, key, algorithms=["EdDSA"], issuer=ISSUER) # step 1, your code proof = verify_user_proof(claims, expected_issuer=ISSUER) # steps 4 and 5 ``` ```typescript import { jwtVerify } from "jose"; import { verifyUserProof } from "@yanez.ai/agent-authorization"; const { payload } = await jwtVerify(artifact, key, { algorithms: ["EdDSA"], issuer: ISSUER }); const proof = verifyUserProof(payload as Record, { expectedIssuer: ISSUER }); ``` **Verify the JWT first.** Running the proof check on an unverified receipt tells you only that someone assembled a self-consistent bundle — anyone can mint one. Lower-level pieces are exported too, for a verifier written in another language or a test harness: | Python | TypeScript | Does | |---|---|---| | `verify_user_proof` | `verifyUserProof` | Steps 4 and 5 together. The one to reach for | | `verify_bls_signature` | `verifyBlsSignature` | Step 4 alone: one signature, the §5 parameters. Returns a boolean, never raises | | `decode_signed_message` | `decodeSignedMessage` | base64url with the canonical-encoding check | | `terms_equal` | `termsEqual` | The comparison rules above | | `parse_json_strict` | `parseJsonStrict` | JSON with duplicate keys and non-finite numbers refused | | `key_is_registered` | `keyIsRegistered` | The registry cross-check below | ## Checking the key against the registry
Development only

This route is live on Development (https://dev3.yanezcompliance.com) and not yet on Test or Production, where it returns 404. The helpers below take the key array as an argument, so they work unchanged once the route ships everywhere.

An agent can ask which keys the registry holds for its own user, and at which tiers: ```http GET /api/agent/user_keys Authorization: Bearer yak_... ``` The YID comes from the agent key. There is no YID parameter, and one sent as a query string is ignored, so this cannot be used to enumerate anyone else's keys. ```json { "yid": "a1b2c3...", "keys": [ {"tier": "high", "public_key": "0xa3c1..."}, {"tier": null, "public_key": "0x55dd..."} ] } ``` | Field | Meaning | |---|---| | `yid` | The YID the agent key belongs to. Compare it with the receipt's `sub` | | `keys` | Every registered key, oldest first. An empty list is a `200`, not an error | | `keys[].tier` | `low`, `medium`, `high`, or `null` when the registry holds no recognized tier. A `null`-tier key never verifies a decision | | `keys[].public_key` | `0x` + lowercase hex, spelled exactly like the `yanez_user_public_key` claim | A missing, malformed, or revoked agent key returns `401`. Device details such as platform and registration time are not returned. Two uses, both optional. The first checks a receipt's key: ```python from yanez_authz import key_is_registered keys = (await client.user_keys()).keys # client is your AuthorizationClient if not key_is_registered(receipt.user_proof.public_key, receipt.assurance_tier, keys): ... # the receipt names a key the registry does not hold at that tier ``` ```typescript import { keyIsRegistered } from "@yanez.ai/agent-authorization"; const { keys } = await client.userKeys(); // client is your AuthorizationClient if (!keyIsRegistered(receipt.userProof.publicKey, receipt.assuranceTier, keys)) { // the receipt names a key the registry does not hold at that tier } ``` Where the route is not deployed, `user_keys()` / `userKeys()` raises `FeatureUnavailableError`. **Read it at verification time.** Keys carry no revocation state, so a cached copy proves nothing about the registry today. **Normalize if you compare by hand.** The route and the receipt claim now use the same spelling, but a key copied from anywhere else may lack the `0x` prefix or use uppercase hex. A raw string compare that misses reads as *this key is not the user's* — the most alarming possible way to be wrong. The helpers normalize both sides; if you compare by hand, strip the prefix and lowercase first. The second use is pre-flight: a tier **absent** from the list cannot be signed at, so an agent whose policy floor is `high` knows the request is futile before it prompts anyone. A tier **present** proves only that the registry holds a key — the device may be lost, and a scan on it may reach no tier at all. Absence is conclusive; presence is permission to try. **This is not independent verification.** The registry and the receipt have the same operator. It catches a receipt whose embedded key was substituted while the registry was intact, and it is worth exactly that much. ## Recovering a lost consume `consumer_token` exists for one scenario: you consumed a receipt, and the response never arrived. Retrying with the same token now has a distinct outcome. | Outcome | What happened | What to do | |---|---|---| | Success | You won the reservation | Execute, carrying your idempotency key | | `ReservationHeldError` | **You** already hold it, from an attempt whose response was lost | **Reconcile** with the ORIGINAL idempotency key. The action may already have happened | | `AlreadyConsumedError` | Someone else holds it | Never execute | `ReservationHeldError` is a recovery, not a refusal — the opposite of `AlreadyConsumedError`. 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. Write the consumer token and an idempotency key derived from `jti` **durably before** consuming. Both must survive the crash they exist to recover from. ## What the user's signature proves **Proved.** The private key matching `yanez_user_public_key` signed this exact decision message, for this request, at the tier named inside it. The signature covers the exact bytes, terms included. **Not proved by the signature alone.** A biometric match, a trustworthy device clock, ownership of the device that submitted it, or that the app selected the highest tier the scan could achieve. Those remain Yanez assertions, authenticated by the issuer signature. A tier downgrade lowers assurance and is caught by your tier floor, not by the signature. **Not established.** Revocation freshness. No key carries a revoked state, so a receipt signed by a device later lost still passes every check here. And nothing binds a public key to a legal identity; that path is not provided. ## Upgrading The Python SDK moves to `0.1.0b5` and the TypeScript SDK to `0.1.0-beta.6`. Two breaking changes: - **`consume` requires `consumer_token`.** An un-updated caller fails at the call site rather than silently producing a token that cannot survive a lost response. - **Verification now requires the user proof.** Receipts minted before this change fail. They record real approvals; they are simply not user-signed ones, and they must not be consumable under a contract that says they are. Also check: - `Terms` gains a required `schema_version` and drops `amount.display`. In TypeScript the compiler will find every literal for you. - `VerifiedReceipt` gains `assurance_tier`, `signed_at`, and `user_proof`. - Replace any `isDeepStrictEqual`-based terms comparison with `termsEqual` / `terms_equal`. The Python SDK adds a `py_ecc` dependency and the TypeScript SDK adds `@noble/curves`, both pure-language implementations with no build step. Two independent implementations verifying the same conformance vectors is deliberate: a library checking its own output proves the plumbing, not the parameters.