#Verifiable Delegated Agent Credentials (VDAC) — v1 profile
Version 0.1.16 · Public draft · 2026-10-04 (first published 2026-08-23; see the change log)
#Status of this document
This is a public working draft, published for review. It describes version 1 of
the VDAC credential profile (vct: urn:otcp:vdac:1) and its verification algorithm,
as implemented and shipped.
The specification is derived from a working implementation, not ahead of one. Where this document and the reference implementation disagree, the implementation is correct and this document has a bug — please report it. The normative surfaces (payload schema, canonical action hashing, the eleven verification steps, and the reason-code vocabulary) are stable enough to build against. §12 lists what is explicitly not settled.
Nothing here is a standards-track document of any standards body. VDAC is a profile over existing standards — SD-JWT VC, OpenID4VP, and IETF Token Status List — not a replacement for any of them.
#1. Introduction
An AI agent that acts for a person needs to answer one question at the moment it acts: on whose authority?
Bearer tokens answer it badly. An API key says "someone who had this key" — it does not say who authorized it, what they authorized, whether that authorization has been withdrawn, or whether the human is accountable for the outcome. When the actor is autonomous and the action moves money or creates an obligation, "someone had the key" is not a satisfying answer to a regulator, a merchant, or the person whose account was drained.
A VDAC is a credential in which a KYC-verified person delegates scoped, attenuating, revocable authority to a specific agent key. It is:
- self-issued by the principal's wallet and signed by the principal's own secure-element key — no issuer sits in the delegation path;
- rooted in a verified identity by embedding the principal's identity credential, so the chain terminates in an accountable human rather than in another token;
- bound to the agent's non-exportable key, so it is useless to anyone who steals the credential without the key;
- bound to the specific action for anything state-changing, so a captured presentation cannot be replayed against a different transaction;
- revocable at three independent levels, so the person can kill one delegation, one agent key, or every delegation at once.
#1.1 What a VDAC is not
- Not a payment instrument. A VDAC authorizes; it never settles. No rail moves money because a VDAC exists. A verifier that accepts one still performs its own settlement under its own licence.
- Not an identity credential. It carries no attributes of its own. It embeds an identity credential to establish accountability, and it discloses nothing from it by default.
- Not a trust list. Accepting a VDAC is a verifier policy decision. This profile defines what a verifier can check, never who it must trust.
#2. Terminology
| Term | Meaning |
|---|---|
| Principal | The verified human who delegates. Holds the identity credential and the key that signs VDACs. |
| Agent | The autonomous software the authority is delegated to, identified by a non-exportable key. |
| Verifier | The party evaluating a presentation against its own policy — typically on behalf of a relying party. |
| Relying party (RP) | The merchant, service, or system relying on the verdict. |
| Root VDAC | The VDAC signed by the principal, embedding the identity credential. |
| Sub-VDAC | A VDAC signed by an agent, re-delegating a subset of its own authority. |
| Chain | A leaf VDAC plus every ancestor up to the root. A lone VDAC is a chain of length 1. |
| Action | The concrete operation being authorized, in canonical form (§5.2). |
| SUA | Step-Up Assertion — the principal's signed approval of one specific action (§5.4). |
The key words MUST, MUST NOT, SHOULD, and MAY are to be interpreted as in RFC 2119.
#3. Design invariants
Seven invariants hold for every VDAC and every verification. They are the reason the format looks the way it does, and a profile that drops one is not this profile.
| Invariant | Enforced by | |
|---|---|---|
| I1 | Attenuation only. A child's authority is a subset of its parent's. Authority never widens on delegation. | §6 steps 4b, 7, 8 |
| I2 | Non-transferable agent key. The agent key is hardware-rooted and non-exportable; it moves only by the principal re-issuing. | §4.2 cnf, §6 step 5 |
| I3 | Action-bound presentation. State-changing classes bind the exact action by hash; read-only classes bind nonce and audience. | §5.2, §6 step 6 |
| I4 | Bounded autonomy. Above a threshold — the principal's or the verifier's — a human approves that specific action. | §5.4, §6 step 10 |
| I5 | Three-level revocation with bounded freshness. Principal root, delegation, and agent key revoke independently, each with a status-age bound. | §4.4, §6 step 9 |
| I6 | Non-repudiation without identification. The principal signs the chain; the verifier learns a pseudonym, not an identity. | §9 |
| I7 | Typed authority, fail-closed. Authority is typed; an unrecognized type denies. | §4.3, §6 step 3 |
#4. Credential format
#4.1 Envelope
A VDAC is an SD-JWT VC (media type dc+sd-jwt). In v1 the payload uses no
selective disclosure of its own — the disclosure machinery matters for the embedded
identity credential, not for the authority claims, which the verifier needs in full.
The JWS protected header:
{ "alg": "ES256", "typ": "dc+sd-jwt", "crit": ["otcp_authority"] }
otcp_authority is a critical extension: a verifier that does not understand
typed authority MUST reject the credential rather than ignore the claim (I7). A
verifier MUST reject any crit member it has not registered (§6 step 3).
#4.2 Payload
| Claim | Type | Req. | Meaning |
|---|---|---|---|
vct |
"urn:otcp:vdac:1" |
✔ | Credential type. |
jti |
string (UUID) | ✔ | Stable VDAC id. Usage is accounted per delegation line, not per jti (step 8). |
iss |
did:jwk:… |
✔ | The signer. On a root VDAC, the principal's key; on a sub-VDAC, the delegating agent's key. |
sub |
string | ✔ | The agent this authority is granted to (did:jwk: in v1). |
cnf |
{ jwk } |
✔ | The agent's non-exportable holder key (I2). The KB-JWT verifies under this key. |
principal |
{ otcp_id } |
root only | The embedded identity credential, compact SD-JWT (§4.6). |
parent |
string | sub only | The parent VDAC, embedded compact (§4.5). |
otcp_authority |
array | ✔ | Typed authority grants (§4.3). Critical. |
delegable |
false | { depth, narrowing_required } |
✔ | Opt-in re-delegation. Default false (§4.5). |
mode |
"projection" | "mediated" |
✔ | v1 implements projection only. |
linkability_path |
"none" |
✔ | Reserved escrow hook; always "none" in v1 (§9). |
freshness |
{ max_status_age_default } |
✔ | ISO 8601 duration bounding status age (I5). |
iat, exp |
number | ✔ | Unix seconds. A sub-VDAC MUST NOT outlive its parent. |
status |
object | ✔ | The three status entries (§4.4). |
agent_key_attestation |
object | ✖ | Evidence about how the agent key is held (§4.7). |
agent_meta |
object | ✖ | Descriptive agent metadata. Never a trust input. |
#4.3 Authority classes
Authority is a list of typed grants. Five classes exist in v1; an unrecognized
type MUST deny (I7).
present-attribute — disclose a verified claim for a decision. No onward-use
grant. Baseline binding only.
{ "type": "present-attribute", "attributes": ["age_equal_or_over.18"] }
act — operate a resource. verb: "write" requires action-binding; "read" is
baseline. An act carries no amount, so a stepup_over on an act grant holds
every write for a Step-Up Assertion, whatever its value: "0" (a wallet's "ask me
for every action") and any other figure mean the same.
{ "type": "act", "verb": "write", "resource": "calendar",
"counterparties": { "allow": ["*"], "deny": [] }, "velocity": { "count": 10, "window": "P1D" } }
spend — move funds up to caps. The rail moves the money; the VDAC is a
constrained mandate.
{ "type": "spend", "currency": "EUR", "per_tx_max": "200.00", "cumulative_max": "500.00",
"counterparties": { "allow": ["*"], "deny": [] }, "stepup_over": "100.00",
"recurrence": "one_shot" }
commit-obligate — bind the principal to a future obligation. Carries
total_liability_max. Always triggers step-up (§6 step 10).
sign-attest — a non-repudiable statement in the principal's name, bound to a
document hash. Carries an optional purposes allow-list. Always triggers
step-up.
Common optional fields: counterparties: {allow, deny} (matched on the canonical
payee identifier, not a display name), jurisdictions, velocity: {count, window},
recurrence, stepup_over.
All monetary amounts are string decimals at the currency's scale —
"200.00", never200.0. Floats are not permitted anywhere in this profile. Arithmetic is exact (the reference implementation is BigInt-backed).
#4.4 Status
"status": {
"principal_root": { "status_list": { "uri": "https://status.example/lists/principals", "idx": 42 } },
"delegation": { "status_list": { "uri": "https://status.example/lists/vdacs", "idx": 7 } },
"agent_key": { "status_list": { "uri": "https://status.example/lists/agents", "idx": 3 } }
}
Three independent IETF Token Status List entries (I5), which is what makes revocation useful rather than nominal:
principal_root— revoking it kills every delegation the person ever made. It reuses the identity credential's own status entry, so losing the identity loses the delegations automatically.delegation— kills this VDAC and everything delegated below it.agent_key— kills one agent's key across the delegations that bind it.
Lists MUST be large and shared, never per-principal: a list whose size or URL is unique to one person leaks that person. The principal→index map is private to the status service.
Status values. An entry holds one of the Token Status List values:
| Value | Meaning | Reversible |
|---|---|---|
0 |
Valid. | — |
1 |
Revoked. | No. A revocation is final: the status service MUST refuse to move an entry off 1 on the holder's own credential. |
2 |
Suspended: the principal has paused this link. | Yes, back to 0 (resume) or on to 1 (revoke). |
3 |
Reserved by this profile. | — |
Suspension needs a list of at least 2 bits per entry; a 1-bit list can only say
valid or revoked. The delegation and agent_key lists SHOULD be 2 bits wide so
a principal can pause an agent without ending its delegation. A verifier reads
the width from the status list token (status_list.bits), never assumes it.
freshness.max_status_age_default bounds how old a status list may be and still be
acted on. The age of a list is now − iat of the status list token itself — how
long ago the status service asserted those bits, not how long this verifier has held
them. Measuring from iat is what makes the bound mean anything: it is the only
reading under which the bound limits revocation latency (§8), and it makes a cached
list age out on its own rather than requiring a separate cache rule. A status list
token that carries no iat therefore has no provable age and cannot satisfy any
bound.
#4.5 Delegability and sub-VDACs
delegable is false unless the principal opts in:
"delegable": { "depth": 1, "narrowing_required": true }
A sub-VDAC omits principal, carries the compact parent in parent, and is signed
by the delegating agent's key — which MUST equal the parent's cnf key. Root
binding is reached by walking up the chain. v1 implements same-principal projection
only: mode MUST be "projection". Chains are bounded at 6 links by the reference
implementation.
Every constraint in §6 step 4b applies per link: signature, delegator linkage, depth, attenuation, and expiry. A sub-VDAC that widens authority, outlives its parent, or exceeds the permitted depth is rejected at both issuance and verification — "no free chains".
#4.6 Root binding by embedding
The root VDAC embeds the principal's identity credential in principal.otcp_id as a
compact SD-JWT. The identity credential's cnf key MUST equal the root VDAC's iss
key — which means the root VDAC's own signature is the holder proof-of-possession
for the identity credential. No separate PoP is transmitted, and the agent never
holds the principal key.
The embedded credential SHOULD carry no disclosures unless the verifier's policy
requires a specific attribute. The wallet embeds <issuer-jwt>~ — issuer JWT only —
so a delegation reveals no name, no date of birth, nothing beyond the assurance level
and the binding key.
#4.7 Agent-key attestation
"agent_key_attestation": { "level": "hardware_tee", "platform": "android", "verified_at": 1787000000 }
level is one of hardware_strongbox, hardware_tee, software (extensible).
This field MUST be absent unless the issuing wallet actually verified the protection of the key it is attesting to. A wallet that cannot inspect the agent key — which is the normal case for a remote agent — MUST NOT emit the field. An absent attestation is a truthful statement about missing evidence; a default-filled one is a lie a verifier's policy might rely on.
Verifiers SHOULD treat this as one input among several, never as a substitute for the status checks.
#5. Presentation
#5.1 The bundle
{ "vdac": "<compact SD-JWT>", "kbJwt": "<compact JWS>", "sua": "<compact JWS, when step-up fired>" }
Self-contained: the identity root travels inside the VDAC, so the verifier needs no prior relationship with the wallet.
#5.2 Canonical actions and action hashing
For state-changing classes the presentation binds the exact action:
action.hash = base64url(SHA-256(JCS(action_object)))
where JCS is RFC 8785 JSON Canonicalization. Canonical action objects carry exactly these fields and no others:
| Class | Fields |
|---|---|
spend |
type, amount, currency, payee, ref, nonce |
commit-obligate |
type, doc_hash, total_liability, currency, counterparty, term, ref, nonce |
sign-attest |
type, doc_hash, purpose, ref, nonce |
act |
type, verb, resource, ref, nonce |
Two properties make this reproducible across languages, which matters because the wallet that signs and the verifier that checks are usually not written in the same one:
- Every value is a string. No numbers appear in a canonical action, so JCS number serialization — its sharpest edge — never applies.
payeeandcounterpartyare canonical machine identifiers (IBAN, pay address, account id), never display names. A display name is spoofable; the whole point of hashing the action is defeated if the identifier can be re-pointed.
Test vector. For
{ "type": "spend", "amount": "180.00", "currency": "EUR",
"payee": "merchant-demo", "ref": "ord-1", "nonce": "n-1" }
the canonical JSON is exactly
{"amount":"180.00","currency":"EUR","nonce":"n-1","payee":"merchant-demo","ref":"ord-1","type":"spend"}
and the hash is NnYIwGeBcVWkjYSM6tmkqHdDD_CDmnmeNQEM9NZIoYQ. An implementation that
does not reproduce this string byte-for-byte is not conformant.
#5.3 Key-Binding JWT
Signed by the agent key (the VDAC's cnf):
{ "nonce": "<verifier nonce>", "aud": "<verifier origin>", "iat": 1787000000,
"action": { "type": "spend", "alg": "sha-256", "hash": "<action hash>",
"amount": "180.00", "currency": "EUR", "payee": "merchant-demo",
"ref": "ord-1", "nonce": "n-1" } }
action carries the hash and the cleartext fields, so the verifier reconstructs
the action from its own record of the transaction and re-hashes — it never trusts
the agent's claimed amount or payee. action is absent for baseline (read-only,
present-attribute) presentations.
#5.4 Step-Up Assertion
When step-up fires, the presentation MUST include an SUA signed by the principal key:
{ "action_hash": "<must equal the KB-JWT action hash>", "method": "biometric",
"loa": "high", "principal": "did:jwk:…", "aud": "<verifier origin>",
"nonce": "<verifier nonce>", "iat": 1787000000,
"biometric": { "verified": true, "method": "biometric_strong",
"provider": "android_keystore", "at": 1787000000 } }
method is one of oob, biometric, fresh_mint. The verifier receives the
wallet's signed assertion that liveness occurred — never biometric data, which
does not leave the device.
Because the SUA is signed by the principal key and bound to action_hash, an agent
that has been fully compromised — holding its own key and a valid VDAC — still cannot
manufacture one. That is the property step-up exists for.
Default freshness is 300 seconds. Wallets SHOULD expire a step-up request no later than that, so an approval cannot outlive the assertion it produces.
#6. Verification
A conformant verifier performs these steps in order, denying at the first failure. Each returns a machine-readable reason code (§7).
1 — Parse and walk. Parse the leaf VDAC and KB-JWT, then follow parent to the
root. A lone VDAC is a chain of length 1. Malformed input, or a chain deeper than the
implementation limit, denies (vdac.malformed).
2 — Algorithm policy. Every declared alg — each chain link, the KB-JWT, the
embedded identity credential, and the SUA — MUST be in the accepted set
(vdac.alg_not_accepted).
3 — Critical extensions. Any crit member on any link that the verifier has not
registered denies (vdac.unknown_crit).
4 — Identity root. On the root link: the embedded identity credential's issuer
keys MUST resolve (vdac.issuer_unresolved, followed by a second reason saying why:
the issuer.* code of the verifier's trust source, such as issuer.not_authorized,
or issuer.unavailable when that source could not be reached); its issuer signature
MUST verify against them (vdac.root_sig_invalid); every
disclosure MUST be bound to a digest in the payload (vdac.root_disclosure_unbound);
the credential's cnf key MUST equal the root's iss key (vdac.iss_cnf_mismatch);
the root VDAC signature MUST verify under that principal key
(vdac.vdac_sig_invalid); the assurance level MUST meet the policy floor
(vdac.assurance_too_low); and any attributes the policy requires MUST be disclosed
(vdac.attribute_missing).
Assurance is read from kyc_assurance, assurance, or loa, and the ladder is
low < substantial < high. It is fail-closed: a missing or unrecognized level
never satisfies a floor.
4b — Per-link chain walk. For each non-root link: its signature MUST verify under
its own iss key (vdac.link_sig_invalid); that key MUST equal the parent's cnf
key (vdac.chain_link_broken); mode MUST be projection (vdac.mode_unsupported);
the parent's delegable.depth MUST cover this hop (vdac.parent_not_delegable); the
child's authority MUST attenuate the parent's (vdac.scope_widened); and the child
MUST NOT outlive the parent (vdac.exp_exceeds_parent).
4c — Validity period. Every link, the root included, and the embedded identity
credential MUST be in date: an exp at or before the verifier's clock less its skew
allowance denies (vdac.expired), with a second reason naming which, delegation or
otcp_id. Every link is read, not only the leaf, so a parent's date bounds a child that
carries none. This is the only place a clock meets an exp; step 4b orders a child
against its parent and reads no clock.
5 — Agent proof-of-possession. The KB-JWT MUST verify under the leaf's cnf key
(vdac.pop_invalid), with matching nonce (vdac.nonce_mismatch) and aud
(vdac.aud_mismatch), and an iat not in the future beyond clock skew
(vdac.kb_not_fresh).
6 — Action binding. For state-changing classes the KB-JWT MUST carry an action
(vdac.action_missing) whose nonce matches the presentation nonce
(vdac.action_nonce_mismatch) and whose hash equals the verifier's own recomputation
from the canonical action (vdac.action_mismatch).
7 — Attenuation. The action MUST fall inside a matching grant on the leaf: class, currency, asset, counterparty allow/deny, jurisdiction, per-transaction ceiling, granted verb, granted attributes. Failures are specific — see §7.
8 — Caps. Cumulative and velocity limits are evaluated against usage accounted
per delegation line: the leaf's iss and the RFC 7638 thumbprint of its cnf
key (vdac.cumulative_exceeded, vdac.velocity_exceeded,
vdac.total_liability_exceeded). A replacement (§10.3), a new jti for the same
agent key from the same delegator, continues the line of the delegation it replaces,
so what was spent under one still counts under the next; a new agent key starts a new
line. On accept the verifier commits the delta to the line, keyed on the action's
ref so a retried submission is idempotent. A verifier that kept usage per jti MAY
seed a line from the presented delegation's jti count the first time it sees the
line.
9 — Status and freshness. The root's principal_root, plus every link's
delegation and agent_key, MUST be valid (status 0, §4.4). A revoked entry denies
with vdac.revoked.<level>, a suspended one with vdac.suspended.<level>, and any
other value with vdac.status_unknown.<level>; all three deny every class,
present-attribute included, because each is the status service's answer rather than
its absence. A missing entry denies (vdac.status_missing). For state-changing
actions an unavailable status list denies (vdac.status_unavailable) —
fail-closed; for present-attribute and read-only actions it MAY fail open, a
deliberate trade of strictness for availability where nothing changes state.
Each list MUST also be fresh. The bound is the tightest of the verifier's
maxStatusAge and every chain link's own freshness.max_status_age_default: both are
floors asserted by different parties — the principal in the credential, the relying
party in policy — so the stricter governs, and a link may no more loosen its parent's
freshness than it may outlive it (step 4b). A list whose age (§4.4) exceeds the bound
is unusable and denies with vdac.status_stale, under the same fail-closed /
fail-open split as unavailability — a stale list is as useless as an unreachable one.
Verifiers MUST NOT widen the bound by a clock-skew allowance: quietly loosening a
floor the relying party set is the same defect as not enforcing it. A duration that
cannot be parsed denies (vdac.malformed); it MUST NOT be treated as "no bound".
Revocation outranks everything else in this step — a revoked entry anywhere in the
chain is reported as vdac.revoked.<level> even when another link is suspended or a
list is stale, since it is the permanent fact and a retry will not clear it. Then an
unknown value, then a suspension, then staleness: a relying party told
vdac.suspended may reasonably try again later, so it MUST NOT be told that about a
chain that is also revoked.
10 — Step-up. Step-up fires on the union of triggers: the verifier's route
policy, the class defaults (commit-obligate and sign-attest always), the
principal's own stepup_over threshold on value classes, and any stepup_over on
the act grant a write matches. When it fires, a valid SUA
MUST be present — signed by the principal key (not an intermediate agent), bound to
this action hash, matching aud and nonce, and fresh. Absent or invalid, the
verdict is review with vdac.stepup_required — not a denial. The transaction is
recoverable: obtain the human's approval and re-submit the same action.
11 — Accept. Return the verdict with the assurance label, the usage delta to
commit, and a chain summary (iss, sub, jti, depth, grantType).
#6.1 Verifier policy inputs
acceptedAlgs, registeredCrit, minAssurance, aud, nonce,
requiredAttributes, jurisdiction, maxStatusAge, stepUpRoutePolicy,
stepUpMaxAgeSec (default 300), clockSkewSec (default 60).
Policy belongs to the verifier. This profile defines what can be checked; it never dictates thresholds.
#7. Reason codes
Verdicts are pass, review, or block, with machine-readable reasons. RPs SHOULD
branch on these codes rather than on message text.
| Code | Meaning |
|---|---|
vdac.malformed |
Unparseable, wrong shape, or chain too deep. |
vdac.alg_not_accepted |
A declared algorithm is outside policy. |
vdac.unknown_crit |
Unregistered critical extension. |
vdac.issuer_unresolved |
Identity-credential issuer keys could not be resolved; the reason after it says why: an issuer.* code of the verifier's trust source (for example issuer.not_authorized), or issuer.unavailable when the source could not be reached. |
vdac.root_sig_invalid |
Identity credential signature invalid. |
vdac.root_disclosure_unbound |
A disclosure is not bound to a payload digest. |
vdac.iss_not_did_jwk |
An iss is not a resolvable did:jwk. |
vdac.iss_cnf_mismatch |
Identity cnf ≠ root iss — the chain is not rooted in this identity. |
vdac.vdac_sig_invalid |
Root VDAC signature invalid under the principal key. |
vdac.assurance_too_low |
Assurance below the policy floor (fail-closed). |
vdac.attribute_missing |
A policy-required attribute was not disclosed. |
vdac.link_sig_invalid |
A chain link's own signature is invalid. |
vdac.chain_link_broken |
A link is not signed by its parent's cnf holder. |
vdac.mode_unsupported |
Non-projection mode. |
vdac.parent_not_delegable |
Parent forbids this depth of re-delegation. |
vdac.scope_widened |
A child claims authority its parent lacks (I1). |
vdac.exp_exceeds_parent |
A child outlives its parent. |
vdac.expired |
A link, or the embedded identity credential, is past its exp less the skew allowance; the reason after it says which, delegation or otcp_id. |
vdac.pop_invalid |
KB-JWT does not verify under cnf. |
vdac.dpop_key_mismatch |
OAuth binding (§10.1): the DPoP proof's key is not the VDAC's cnf key. |
vdac.nonce_mismatch / vdac.aud_mismatch |
Replay or wrong audience. |
vdac.kb_not_fresh |
KB-JWT iat outside the accepted window. |
vdac.action_missing |
State-changing action with no bound action. |
vdac.action_mismatch |
Action hash ≠ the verifier's recomputation. |
vdac.action_nonce_mismatch |
Action nonce ≠ presentation nonce. |
vdac.no_matching_grant |
No grant of the required class. |
vdac.currency_mismatch / vdac.asset_mismatch |
Wrong denomination. |
vdac.counterparty_not_allowed |
Payee outside allow/deny. |
vdac.jurisdiction_not_allowed |
Outside granted jurisdictions. |
vdac.verb_not_granted |
act verb not granted. |
vdac.attribute_not_granted |
Attribute outside the present-attribute grant. |
vdac.per_tx_exceeded |
Above the per-transaction ceiling. |
vdac.cumulative_exceeded |
Above the cumulative ceiling of this delegation line (step 8). |
vdac.velocity_exceeded |
Too many accepted actions in the window. |
vdac.total_liability_exceeded |
Above the obligation ceiling. |
vdac.status_missing |
A required status entry is absent. |
vdac.status_unavailable |
Status list unreachable (denies for state-changing). |
vdac.status_stale |
Status list older than the freshness bound, or of unprovable age (denies for state-changing). |
vdac.revoked.principal_root |
The person's root is revoked — every delegation is dead. |
vdac.revoked.delegation |
This delegation (and its subtree) is revoked. |
vdac.revoked.agent_key |
This agent key is revoked. |
vdac.suspended.<level> |
That link is paused by the principal (status 2); it may be resumed, so a retry after resumption can pass. <level> is principal_root, delegation or agent_key. |
vdac.status_unknown.<level> |
That link's status is a value this profile does not define (3, or above on a wider list). |
vdac.stepup_required |
review — a human must approve this action. |
#8. Security considerations
What a stolen VDAC gets an attacker: nothing. The credential is public-ish data. Presenting it requires signing a fresh KB-JWT under the agent key, which is non-exportable.
What a fully compromised agent gets an attacker: the granted scope, and only until revocation. This is the honest bound and the reason scoping and caps are first-class. An attacker holding the agent key can act within the grant — up to the per-transaction ceiling, up to the cumulative ceiling, only to allowed counterparties, only until any of three status bits flips. They cannot exceed the caps, re-point the payee, widen the scope, or produce an SUA.
Action re-pointing is prevented by hashing canonical machine identifiers and by the verifier reconstructing the action from its own order — never from the agent's claim. A verifier that hashes the agent's asserted amount instead of its own has implemented this profile incorrectly.
Replay is prevented by verifier-chosen single-use nonces bound into both the
KB-JWT and the action object, plus iat freshness on both KB-JWT and SUA.
Delegation-chain abuse is prevented by per-link verification (§6 step 4b) — every link is signed by its delegator, attenuates, and expires no later than its parent; step 4c then refuses any link, and any identity credential, that is past its date.
Revocation latency is bounded by the tighter of freshness.max_status_age_default
and the verifier's maxStatusAge, measured from the status list token's own iat
(§4.4). Status is checked at every verification, and a list older than the bound is
not usable — whether it came from a cache or from a service that has stopped
republishing. A bound measured from fetch time instead would not bound this at all: a
service issuing one list a day would satisfy any hourly refetch rule while its bits
were a day stale.
Known limits, stated plainly. Agent-key attestation is only as good as the evidence behind it, and this profile explicitly permits its absence (§4.7) rather than encouraging a fabricated claim. Status availability is a liveness dependency for state-changing actions — the fail-closed choice is deliberate, and it means a status outage stops transactions. Sub-delegation is single-principal projection only; nothing here addresses delegation across principals.
#9. Privacy considerations
Delegation discloses no identity attributes. The embedded identity credential
carries no disclosures by default — the verifier learns a pseudonymous did:jwk, the
assurance level, and nothing else. Where a grant needs an attribute, exactly that
attribute is disclosed and no more.
Accountability without identification (I6). The chain is principal-signed, so an action is attributable and non-repudiable, while the verifier holds only a pseudonym. Identifying the person behind it is an out-of-band legal process against the identity issuer, not something a verifier can do from a presentation.
Status lists are herd-privacy structures. Large shared lists, never per-principal — a list whose size or URL identifies one person defeats the purpose.
Audit logs. Verifiers SHOULD record the verdict, reason codes, jti, and
pseudonymous iss/sub — and SHOULD NOT record identity attributes.
Multi-show linkability is a real limitation of v1. An SD-JWT VC presented twice carries the same issuer signature, so two colluding verifiers can correlate. Batch issuance of single-use copies is the intended mitigation and is not yet part of this profile (§12).
linkability_path is always "none" in v1. The field is reserved for a future
opt-in escrow construction. No implementation may treat it as an unmasking hook
today.
#10. Interoperability
VDAC is a profile over standards, not a parallel stack:
- SD-JWT VC for the credential envelope and the embedded identity credential.
- OpenID4VP for the presentation transport.
- IETF Token Status List for revocation.
- RFC 8785 (JCS) for canonical action hashing.
- RFC 9421 where the transport itself is signed (HTTP Message Signatures) — the usual pattern for an agent calling an API.
AP2 Intent Mandate. A VDAC spend grant is a superset of a Google AP2 Intent
Mandate and exports to one losslessly: merchants ↔ counterparty allow-list, intent
expiry ↔ exp, cart-confirmation ↔ step-up reachability. Descriptive AP2 fields with
no authority meaning are carried verbatim. The generated field map ships with the
reference implementation.
#10.1 OAuth binding
A relying party that takes OAuth access tokens rather than VDACs lets the agent trade
its VDAC for one, per action, at an authorisation server that runs §6. In the reference
implementation that server is the OTCP verifier, one per relying party (tenant), at
<verifier>/as/<tenant>. Every piece is an existing standard; this section names what
VDAC adds and fixes how the pieces combine.
| Standard | Its job here |
|---|---|
| RFC 8414 | Metadata at /.well-known/oauth-authorization-server/as/<tenant> (path insertion). |
| RFC 8693 | Token exchange; the VDAC presentation is the subject_token. |
| RFC 9396 | authorization_details carries the one action. |
| RFC 8707 | resource names the relying party's API and becomes the token's aud. |
| RFC 9449 | DPoP binds the token to the agent key. The server's DPoP-Nonce is also the KB-JWT's and the action's nonce. |
| RFC 9068 | The access token: a JWT, typ: at+jwt. |
| RFC 7662 | Introspection, for the relying party alone. |
| RFC 9470 | insufficient_user_authentication when the principal must approve. |
| RFC 9728 | The relying party's protected-resource metadata and its 401 challenge, which tell an agent where the authorisation server is. |
Vocabulary (the constants in the reference implementation's profile.ts):
| Name | Kind | Meaning |
|---|---|---|
urn:otcp:params:oauth:token-type:vdac |
subject_token_type |
The subject token is the VDAC, its disclosures and the KB-JWT: <vdac-jwt>~<disclosure>~…~<kb-jwt>. |
urn:otcp:vdac:action |
authorization_details type |
{ "type": …, "action": <a §5.2 action object> }. Exactly one entry, with no other members. |
step_up_assertion |
Token-request parameter | The §5.4 SUA, on a retry. |
step_up |
Error member | { action_hash, nonce, expires_in } on insufficient_user_authentication. |
urn:otcp:loa:<level> |
acr value |
The identity root's assurance: low, substantial or high. Not an eIDAS level of assurance. |
otcp_vdac |
Access-token claim | { jti, stepped_up }: the leaf VDAC's jti, and whether a step-up approved this action. |
otcp_disclosed |
Introspection member | The values of the attributes the relying party's policy required. |
otcp_principal |
Introspection member | How the principal was verified: { liveness, provider, verified_at }, each optional. The facts a direct verdict carries; no name, no document. |
otcp_reasons |
Error member | The §7 reason codes of a refusal. |
otcp_via |
Request claim | The agent a sign-in was asked through, { client_name } (§10.4). |
pairing_code, delivery: "jwe" |
Request-object members | The rendezvous (§10.2). |
otcp_push |
Delivery member | Where the agent asks the wallet's instance to tell the wallet an approval waits, { endpoint, handle } (§10.5). |
otcp-push+jwt |
JWS typ |
The agent's signed push request (§10.5). |
The exchange.
- The agent sends a DPoP proof (ES256;
htm,htu,iat,jti) with no nonce. The server answers400 use_dpop_noncewith aDPoP-Nonce. A nonce is single use, bound to the key it was issued to, and lives 120 seconds. - The agent posts
grant_type=urn:ietf:params:oauth:grant-type:token-exchange, the subject token,resourceandauthorization_details, with a DPoP proof that carries the nonce. The KB-JWT'saudis the authorisation server's issuer. Itsnonce, and the action'snonce, are the DPoP nonce. - The server checks, in this order: the proof; its
jtinot seen before for that key; the nonce unspent, which it then spends; one key; then §6, under the relying party's own policy (assurance floor, required attributes, status freshness, its own step-up trigger), never under anything the agent sends. One key means the DPoP key is the VDAC'scnf(elsevdac.dpop_key_mismatch) and the KB-JWT verifies under it (elsevdac.pop_invalid). - Pass. Usage is committed (§6 step 8) when the token is issued, so a token that is
never used still counts against the caps. The response carries
token_type: DPoP,issued_token_type: urn:ietf:params:oauth:token-type:access_token, anexpires_inof at most 300 seconds, and theauthorization_detailsgranted. - Step-up (§6 step 10 fires and no SUA was sent). The server answers
400 insufficient_user_authenticationwithstep_upand a freshDPoP-Noncen2 that lives 300 seconds and is bound to this one action.action_hashis the hash of the action re-presented withnoncen2. The agent asks the principal for an SUA whoseaudis the issuer and whosenonceis n2, then retries with the KB-JWT, the DPoP proof and the SUA all on n2. An SUA that still does not satisfy step 10 is refused, not asked for again. - Refusal.
400 access_deniedwithotcp_reasons.
The access token carries no personal data. Its claims:
sub: a pairwise handle for the principal, the one a direct presentation of the same identity credential gets at that relying party;client_idandact.sub: the agent;cnf.jkt: the agent key's thumbprint;aud: the resource;acr,authorization_detailsandotcp_vdac.
The relying party verifies the token with the authorisation server's key set and a DPoP
proof, and rejects a reused jti. It receives attribute values, and how the principal
was verified, only by introspection, authenticated as itself, and only within its
retention window.
The relying party's side.
- A request that carries no token is answered
401with the RFC 9728 challenge:WWW-Authenticate: DPoP algs="ES256", resource_metadata="<metadata URL>"(and the sameresource_metadataon aBearerchallenge). The metadata names the authorisation server, requires DPoP-bound tokens and listsurn:otcp:vdac:action. - A request with a token carries
Authorization: DPoP <token>and one DPoP proof. The relying party checks, in this order: the token's signature under the authorisation server's key set,typ: at+jwt,iss,aud(its own resource),exp; the proof (htm,htu,iat,ath= the token's hash) and that its key's thumbprint is the token'scnf.jkt; then the action. - The action is the relying party's own. It computes the action from its own
records (the order, never the request body) and compares it with the token's
authorization_detailsaction. The action'snonceis the authorisation server's (step 2 of the exchange), so the comparison is made under the token's nonce;refis what binds the action to the relying party's record. A mismatch is refused403 insufficient_scope. - The token is spent by its first use: a second request with the same
jtiis refusedinvalid_token. - A relying party that needs the disclosed values, or how the principal was verified, introspects the token with its own API key before acting.
A relying party may keep verifying VDACs itself instead (§6). An agent that signs its
request (RFC 9421) may name where its keys are published with Web Bot Auth: a
Signature-Agent dictionary member covered by the signature, a keyid that is the
key's RFC 7638 thumbprint, tag="web-bot-auth", and a key directory at
/.well-known/http-message-signatures-directory. A relying party that checks the
directory still requires the key found there to be the VDAC's cnf.
#10.2 Rendezvous
An agent with no public address (a command-line agent on a laptop, a service behind
a firewall) cannot serve the request object the wallet fetches, nor take the
wallet's answer. It meets the wallet at a rendezvous instead. In the reference
implementation that is the OTCP verifier, under <verifier>/v1/agents. The
rendezvous relays: it verifies no delegation, and it never holds a credential it
can read.
Vocabulary (the constants in profile.ts):
| Name | Kind | Meaning |
|---|---|---|
pairing_code |
Request-object member | The six decimal digits the agent shows, which the person types into the wallet. |
delivery |
Request-object member | How the wallet answers. "jwe": encrypted to the key in agent.jwk, never in clear. |
agent.operator_uri |
Request-object member | The HTTPS origin of the agent's operator. A wallet shows the agent as operator-verified only when the request object is served from the same registrable domain. |
receipt |
Answer response member | The wallet's credential for reading whether the agent collected its answer. |
Pairing.
- The agent reserves a pairing.
POST /v1/agents/pairingsreturns{ id, code, request_uri, callback, poll_token, expires_in }. The code is unique among live pairings, and a pairing lives 600 seconds. - The agent signs its request object (
typ: otcp-vdac-req+jwt) with the key in itsagent.jwk, carryingpairing_code(the code),callback(the rendezvous's) anddelivery: "jwe", with anexpno later than the pairing's. It puts the object atrequest_uri(PUT,Authorization: Bearer <poll_token>), once. The rendezvous refuses an object not signed by itsagent.jwk, or not bound to the pairing in these three members. - The agent shows the code and its key's fingerprint: the first sixteen characters
of the RFC 7638 thumbprint of
agent.jwk, in groups of four. - The person types the code into the wallet. The wallet resolves it (
GET /v1/agents/pairings/code/<code>→{ request_uri }), then fetches and checks the request object as it checks any other: signed byagent.jwk,callbackon therequest_uri's origin. It shows the code and the fingerprint beside the request. A request served by a rendezvous is not served from the agent operator's domain, so the wallet does not show the agent as operator-verified. - The wallet answers at
callbackwith{ jwe }or{ denied: true, reason? }. The JWE is compact,algECDH-ESon P-256,encA256GCM,kidthe RFC 7638 thumbprint ofagent.jwk, and its plaintext is the body the wallet would otherwise post to an agent's own callback:{"vdac": "<compact VDAC>"}(withreplaceswhen it replaces one, §10.3). The rendezvous refuses anything else, plaintext included. The first answer wins, and a code stops resolving once its pairing has one. The answer returns{ receipt }, once; a later answer is refused409 already_answered, which the wallet tells the person plainly. - The agent polls the pairing (
GET /v1/agents/pairings/<id>,Authorization: Bearer <poll_token>):{ state, jwe?, reason?, expires_in? }, wherestateispending,delivered,deniedorexpired. It decrypts the JWE and checks the VDAC as its own: thecnfis its key, the grant is what it asked for or less. The first poll that returns the answer marks it collected; the answer stays untilexp, so a poll whose reply was lost can be repeated. - The wallet reads whether the agent has its answer (
GET /v1/agents/pairings/<id>/receipt,Authorization: Bearer <receipt>):{ state, collected, expires_in? }, wherestateisdelivered,deniedorexpired. So the person sees "your agent has it", or that the code ran out first; and a wallet replacing a credential revokes the old one only once the new one is collected.
Step-up. The same relay without a code, at /v1/agents/stepups. A step-up
relay lives 300 seconds, the SUA's freshness window (§5.4). The step-up request
object (typ: otcp-stepup-req+jwt) carries delivery: "jwe" and the relay's
callback, and no pairing_code; the agent shows it to the principal as a link or
a QR. The answer's plaintext is {"sua": "<SUA>"}: the SUA names the principal's
key, which the rendezvous has no need to read. Its receipt reads at
/v1/agents/stepups/<id>/receipt.
What the rendezvous keeps. The request object, which names the agent and what
it asks for, not the person; the wallet's ciphertext; the poll token and the
receipt as hashes only; when the agent collected the answer. All of it goes at the
record's exp.
Guessing a code. A code is one in a million, lives ten minutes, and the reference implementation answers ten lookups a minute per client address. One address therefore makes at most a hundred guesses in a code's life, one chance in ten thousand of finding a given live code. A guessed code yields the agent's request object (no personal data) and the chance to answer first: with a denial, or with a delegation from the guesser's own identity, encrypted to the agent's key. It yields neither the person's credential nor their approval. The person sees the first case at once: the wallet's own answer is refused as already answered.
#10.3 Replacing a delegation
A VDAC cannot be edited: its limits and its exp are signed. A person who changes
an agent's limits, or renews them, gets a new VDAC for the same agent key, and the
old one is revoked. An agent already connected may also ask for more (below), and
the person's answer is replaced the same way. The wallet does it in one order, so
the agent is never left without a working credential, nor with two.
Vocabulary (the constants in profile.ts):
| Name | Kind | Meaning |
|---|---|---|
replaces |
Delivery member | The jti of the delegation the delivered one replaces. |
replaces |
Request-object member | The jti of the delegation an agent already connected holds, when it asks for more. |
- The new VDAC is minted under the same rules as the first: never wider than
what the agent last asked for (its ask when it connected, or a later ask for
more that the person answered), its
expno later than that ask allows. A delegation paused (status2) stays paused: the new one'sdelegationentry is written2before the VDAC leaves the wallet. - The wallet delivers it the way the agent was connected, with
replacesbeside it:{"vdac": "<compact VDAC>", "replaces": "<old jti>"}, posted to the agent's own callback, or as the plaintext of the JWE through a fresh pairing (§10.2) when the agent was connected with a code, which the agent shows anew. A key pasted into the wallet has no address: the person copies the new VDAC to the agent. - An agent takes the new VDAC only in place of the one it holds:
replacesnames that credential'sjti, and the new one'scnfis the agent's own key. It refuses anything else (409at a callback), and a second VDAC that names nothing to replace (the first answer wins). - The wallet revokes the old delegation, both its
delegationand itsagent_keyentries, only once the agent has the new one: its callback's2xx, or the rendezvous receipt'scollected. If the new one never arrives (a refusal, no answer, a pairing that ran out), the wallet revokes the new one instead, and the old one stands. A copied VDAC is the exception: the wallet cannot see it arrive, so the old one is revoked at once, and the agent acts again once the person has pasted the new one.
Asking for more. An agent already connected that needs more than its delegation
gives (a higher cap, a grant it lacks, a later end date) asks again, on the same
key: a request object as for a first connection (§10.2, or served at its own
address), carrying replaces, the jti of the delegation it holds, and the whole
ask, not only what grows.
- The wallet refuses the request, and signs nothing, unless it holds the
delegation
replacesnames, given to the key that signed the request, active or paused, with no earlier replacement of it still waiting for the agent to collect. - It shows the ask as a change to that delegation, not as a new connection: each grant as the delegation has it and as asked. The person approves the ask, gives less, or declines.
- Approved, the new VDAC is minted and delivered by steps 1 to 4 above, the way the request says (its callback in clear, or sealed through its pairing). The ask the person answered is the ceiling for later changes.
- Declined, the wallet answers
{ "denied": true }at the request's callback, and the agent keeps the delegation it holds: declining an ask for more ends nothing. An agent that serves its own requests keeps its key and its callback.
A request with no replaces, from a key the wallet already holds a delegation for,
is a new connection. An ask for more is a new consent: it waits for the person, and
an agent can only ask, never raise its own limits.
#10.4 Signing in through an agent
An agent that holds no VDAC can still act for a person who is there to approve each time ("ask me every time"). The agent sends the person to the relying party's authorisation server (§10.1), which runs a presentation from the person's wallet and gives the agent a token. The wallet answers the verifier, never the agent, so the person's data does not pass through the agent or its platform. Every piece is an existing standard:
| Standard | Its job here |
|---|---|
| RFC 6749, RFC 7636 | The authorisation code grant with PKCE (S256 only), for a client with a browser: an MCP client, a browser agent. |
| RFC 8628 | The device grant, for a headless agent: it shows a code, and the person enters it on any screen. |
| RFC 7591 | Registration: public clients only (token_endpoint_auth_method: none), a name and redirect URIs. |
| OAuth client ID metadata document | Instead of registering, a client may be named by an HTTPS URL whose JSON document gives its client_id (that URL), name and redirect URIs. |
| RFC 9207 | The redirect names the issuer (iss). |
| RFC 9449 | DPoP is optional here: a client that proves a key gets a token bound to it (and dpop_jkt binds the code to that key); one that does not gets a Bearer token. |
| OpenID4VP | The sign-in itself: the verifier's signed request object (or the Digital Credentials API request), and the wallet's answer to it. |
Vocabulary (the constants in profile.ts):
| Name | Kind | Meaning |
|---|---|---|
otcp_via |
Request claim | { client_name }: the client's name, as it registered it or as its metadata document gives it. Carried by the signed request object and the Digital Credentials API request of a sign-in, and by no other request. The wallet shows it as "asked through your agent". It is the client's own word, which nobody has checked. |
The sign-in.
- The request (the authorisation request, or the device authorisation request)
names the relying party's API in
resource, which is required and becomes the token'saud. It may name one action inauthorization_details(the type of §10.1) with nononce: the authorisation server sets its own, as in a token exchange, and replaces any the client sent.scopeis refused. - The authorisation server makes a presentation request that asks for what the
relying party's policy requires and nothing else: the identity credential's
assurance claim, against the policy's floor, and any attribute the policy
names. The request carries
otcp_via. The page shows the client's name, what the wallet will share, and, for the device grant, the code to compare with the agent's. - A verified answer ends the sign-in. The code flow redirects with a code that lives 60 seconds and is spent by its first redemption; a code that comes back is refused and the token it bought is revoked. The device flow marks the device code approved. The disclosed values move with the sign-in, from the presentation record to the code or device record and to the token record; the presentation record keeps the claim names only.
- A presentation that fails, or a person who cancels, ends the sign-in with
access_denied: on the redirect for the code flow, at the next poll for the device flow. - The token carries
sub, the pairwise handle that a direct presentation of the same identity credential, and a token exchange by that person's agent, get at the same relying party;client_id;aud;acr;auth_time;cnf.jktwhen it is DPoP-bound; andauthorization_detailswhen an action was asked for. It carries noactand nootcp_vdac: no delegation stands behind it. Like any token of §10.1 it carries no personal data, and introspection hands the relying partyotcp_disclosedandotcp_principal. - A device code lives 600 seconds and is polled every 5 seconds. The token
endpoint answers
authorization_pending,slow_down(after which the client waits 5 seconds longer),access_deniedorexpired_token, then the token once, theninvalid_grant. A user code is eight letters fromBCDFGHJKLMNPQRSTVWXZ, shown asXXXX-XXXXand compared without case, spaces or dashes.
A person can be handed a code, or a link, by someone else (RFC 8628 §5.4). The sign-in page shows the client's name and the code, and the wallet says through which agent the request came: both are the client's own words, so a person signs in only for an agent they asked to act.
#10.5 Telling the wallet an approval waits
A step-up (§5.4) reaches the principal only through a link or a QR code the agent shows, and so does an ask for more (§10.3). An instance may run a push relay through which an agent holding an approval, or asking for more, tells the principal's wallet, without learning the device or reaching it itself; so does an agent that disconnects, whose delegation the wallet still lists. A push carries the link the QR code carries and authorises nothing: the wallet opens the approval or the ask from it and checks the request object as after a scan, or opens the agent, where the principal may revoke it. The link and the QR code stay; every step works without a push.
- A wallet whose principal turned notifications on registers with its instance's relay: its push service's device token and a P-256 push key made for this purpose, never the principal's key. It is off until the principal turns it on.
- With each delegation it delivers (connect, §10.3's replacements), the wallet asks
the relay for a handle and delivers it beside the VDAC:
{"vdac": "…", "otcp_push": {"endpoint": "<the relay's push endpoint>", "handle": "…"}}. The relay encrypts into the handle the installation, the RFC 7638 thumbprint of the VDAC'scnfkey, itsjti, itsexpand its status entries (§4.4), under a key only the relay holds; an agent cannot read it, and two handles never match. An agent keepsotcp_pushwith the delegation it came with, and drops it when a replacement comes without one. An agent posts a push only to the relay of the instance it serves, never to a host anendpointnames otherwise: it keeps nootcp_pushwhoseendpointis not that relay's, so a delivery cannot make it sign a request to another host. - An agent holding an approval posts to
endpointa JWS,algES256,typotcp-push+jwt, its public key asjwkin the header, claimsaud(the endpoint),iat,jti,handle,request_uri(the step-up request object's URL) and optionallyexp(when the approval ends). The request may carrykind:approval(the default),raiseorended. For araise,request_uriis the request object of an ask for more (§10.3), andexpwhen the ask ends. An agent sendsendedonce, at its own disconnect and before it destroys its key, with norequest_uri: the agent no longer acts, and the delegation stays until the principal revokes it or it expires. - The relay refuses unless the JWS verifies under its
jwk;audis its endpoint;iatis within 60 seconds;kind, when present, is one it knows;request_uriis present for an approval or a raise and absent forended; thejtiis unseen for that key; the handle opens and names that key's thumbprint; the delegation'sexphas not passed; every status entry the handle names reads0(a list it cannot read or trust refuses, as step 9 does for a state-changing action), for araiseorendedas for an approval; and the key has sent fewer than ten pushes in the last hour, and for araisefewer than three raises within them. A refused push counts toward neither. - The relay builds the link from the kind, never taking one from the agent:
otcpwallet://stepup?request_uri=…for an approval,otcpwallet://request?request_uri=…for a raise,otcpwallet://agent?key=…forended,keythe thumbprint the handle names. It seals{ "link", "iat", "exp" }to the push key with HPKE (RFC 9180, base mode, DHKEM(P-256, HKDF-SHA256), HKDF-SHA256, AES-128-GCM,infootcp-push-v1,aadthe installation's identifier), and sends the push service{ "v": "1", "enc", "ct" }, kept no longer thanexpand at most five minutes. The push service reads nothing it carries; the notification names no agent, amount or payee. - A wallet opens a
requestlink from a push only as an ask for more: a request object whosereplacesnames a delegation it holds for the key that signed it (§10.3). Anything else it refuses, signing nothing. A wallet opens anagentlink from a push only to show that agent, where the principal may revoke the delegation; it changes nothing by itself.
#11. Conformance
An implementation is conformant if it:
- produces or accepts payloads per §4;
- reproduces the §5.2 canonical hash byte-for-byte, including the published test vector;
- performs §6 in order, denying at the first failure;
- emits §7 reason codes;
- is fail-closed on assurance, unknown
crit, unknown authoritytype, and status availability and freshness for state-changing actions; - where it implements the OAuth binding (§10.1), refuses a token request unless the
DPoP key, the KB-JWT signer and the VDAC's
cnfare one key, and commits usage when it issues a token; - where it implements the sign-in (§10.4), requires PKCE with
S256, spends a code or a device code once, and issues a token carrying no personal data; - where it runs a push relay (§10.5), sends a push only for a request signed by the key its handle names, for a delegation live on every status entry the handle names, and sealed to the wallet's push key, and builds the link from the request's kind.
The reference implementation is @otcp/vdac — pure TypeScript, no service
dependencies, shared by the verifier and the demo harness, and used as the executable
reference for the native wallet minter.
#12. Open issues for v0.2
Named rather than papered over:
- Batch issuance / unlinkability (§9). The mechanism is decided — N single-use copies rotated per presentation — but is not yet profiled here.
- Agent-key attestation format. §4.7 defines the field; it does not yet define how a remote agent conveys an attestation chain, or how a verifier validates one.
- Mediated mode.
modeadmits"mediated"; onlyprojectionis specified. - Cross-principal delegation. Out of scope in v1; the chain walk assumes one principal.
- Post-quantum agility. The
alg/crit/vct-version discipline is in place; no PQC suite is profiled. - Decentralized revocation. Status lists depend on a reachable service. A principal-controlled kill switch that works when the service is down is wanted.
categoriescounterparty scoping. v1 scopes on canonical identifiers only.
#13. Change log
| Version | Date | Notes |
|---|---|---|
| 0.1 | 2026-08-23 | First public draft. Payload, five authority classes, canonical action hashing with test vector, single-hop projection sub-delegation, eleven-step verification, reason-code vocabulary. |
| 0.1.1 | 2026-08-25 | Specified the step-9 freshness bound that §8 already relied on: age measured from the status list token's iat (§4.4), tightest-of-policy-and-credential, new vdac.status_stale reason code. |
| 0.1.2 | 2026-09-20 | Specified step 4c, which §4.2's required exp implied and no step performed: every link and the embedded identity credential in date, with step 5's skew allowance; new vdac.expired reason code. |
| 0.1.3 | 2026-09-24 | Status values (§4.4): 2 = suspended, reversible; 1 = revoked, final; 3 reserved; the delegation and agent_key lists 2 bits wide. Step 9 denies every class on 1, 2 and 3, and orders revoked, unknown, suspended, stale. New vdac.suspended.<level> and vdac.status_unknown.<level> reason codes. |
| 0.1.4 | 2026-09-25 | §10.1 OAuth binding: RFC 8693 token exchange with the VDAC presentation as the subject token, the action in RFC 9396 authorization_details, DPoP whose server nonce is the KB-JWT nonce, the one-key rule, step-up as insufficient_user_authentication, RFC 9068 access tokens with no personal data, and the vocabulary. New vdac.dpop_key_mismatch reason code; conformance item 6. |
| 0.1.5 | 2026-09-25 | §10.1: the relying party's side of the OAuth binding (the RFC 9728 challenge, the token and DPoP checks, the action compared under the token's nonce, the single-use jti), the otcp_principal introspection member, and Web Bot Auth for a signed request. |
| 0.1.6 | 2026-09-25 | §10.2 Rendezvous: an agent with no public address pairs through the verifier with a six-digit code, and relays its step-ups there; the wallet answers with a JWE (ECDH-ES, A256GCM) encrypted to the agent key, so the rendezvous keeps only ciphertext. pairing_code and delivery leave the reserved list. |
| 0.1.7 | 2026-09-25 | Any stepup_over on an act grant holds every write (§4, §6 step 10): an act has no amount, so "0" means every action. §10.2: agent.operator_uri and operator verification by registrable domain; the fingerprint is the thumbprint's first sixteen characters; the answer returns the wallet's receipt, the agent's first poll marks the answer collected, and the wallet reads that at …/receipt. |
| 0.1.8 | 2026-09-25 | §10.3 Replacing a delegation (Change limits, Renew): a new VDAC for the same agent key, delivered the way the agent was connected with the new replaces member naming the old jti; the agent takes it only in place of the one it holds; the wallet revokes the old one only once the agent has the new one, and a paused delegation's replacement starts paused. |
| 0.1.9 | 2026-09-25 | §10.4 Signing in through an agent ("ask me every time"): the authorisation code grant with PKCE and the device grant at the authorisation server of §10.1, clients registered (RFC 7591) or named by a metadata document, DPoP optional, a presentation asking only what the relying party's policy requires, otcp_via in its request, a token with the pairwise sub and no personal data. otcp_via leaves the reserved list. Conformance item 7. |
| 0.1.10 | 2026-09-28 | §10.5 Telling the wallet an approval waits: a push relay at the wallet's instance, a per-delegation otcp_push handle beside the delivered VDAC, the agent's otcp-push+jwt request, the relay's checks (the handle's key, the delegation live, ten an hour) and the link sealed to the wallet's push key with HPKE. Conformance item 8. |
| 0.1.11 | 2026-09-28 | §10.3 Asking for more: replaces is also a request-object member, naming the delegation an agent already connected holds; the wallet refuses one it does not hold, another key's, or one revoked, ended or with a replacement still waiting; it shows the ask as a change to that delegation; the ask the person answered becomes the ceiling (step 1); a declined ask ends nothing. |
| 0.1.12 | 2026-09-28 | §10.5 step 2: an agent posts a push only to the relay of the instance it serves, never to a host a delivery's endpoint names, so a delivery cannot make it sign a request to another host. |
| 0.1.13 | 2026-09-29 | §10.5: a push may also tell the wallet an agent asks for more (kind raise, §10.3); the relay builds the link from the kind, refuses an unknown kind and more than three raises an hour per key; a wallet opens a request from a push only as an ask for more. |
| 0.1.14 | 2026-09-29 | §6 step 8: caps are counted per delegation line (the leaf's iss and its agent key), so a replacement continues the total of the delegation it replaces; the jti is no longer the accounting key. |
| 0.1.15 | 2026-09-30 | §6 step 4, §7: vdac.issuer_unresolved is followed by a second reason saying why the issuer's keys did not resolve, an issuer.* code or issuer.unavailable. §7: vdac.cumulative_exceeded names the delegation line, as step 8 has counted since 0.1.14. |
| 0.1.16 | 2026-10-04 | §10.5: a push may also tell the wallet an agent disconnected (kind ended): sent once, at the agent's own disconnect, before its key is destroyed, with no request_uri; the relay builds otcpwallet://agent?key=… from the key the handle names; the wallet opens that agent from it and changes nothing by itself. |
Feedback is wanted, particularly on §6 and §7 — the surfaces another implementation has to match. The reference implementation is the tie-breaker for any disagreement with this text.