Protocol & specs

Spec: standing agent policies v1

Code: @txpass/protocol (policy.ts: types, buildPolicyPayload, buildAdmitPayload, shape checks, reusing canonicalize from delegation.ts) · @txpass/verify (verifyPolicy, verifyAdmit, pol on TxpassSessionClaims) · @txpass/client (signPolicy, signAdmit, generateEnrollmentCode) · @txpass/agent (txpass-agent enroll <string>, policy enroll|show|list|session|leave) · apps/facilitator (src/policy-store.ts, src/policy-routes.ts: /v1/policies*, POST /v1/session with policy, introspect; /account Agent policies section) · pnpm e2e:policy. Agent-initiated connect (§16): @txpass/protocol connect.ts · apps/facilitator src/connect-store.ts, src/connect-routes.ts, /account/connect · @txpass/agent txpass-agent connect (src/connect.ts) · pnpm e2e:connect.

1. What it does

A delegation v1 cert names exactly one agent key, so every new agent needs a new wallet signature and enrollment codes are impossible. A policy is a cert without a delegate: identity I signs it once, and agents join it through admissions:

  1. keys listed in the policy's agents allowlist;
  2. a later signed admit naming a key (agent);
  3. a signed admit holding only the SHA-256 of an enrollment code (code_hash), redeemed once by the agent's own key.

Each admission gets its own id, which becomes the JWT dlg. Every session therefore traces back to a document signed by I, and each agent can be capped, removed or confirmed separately. The JWT keeps the v1 delegated shape (sub = I, act.sub = A, dlg), plus an additive pol claim.

Non-goals for v1: open policies (any key may mint; rejected as unsafe, the policy id would become a bearer credential), chained or sub-delegated policies, passkey (p256-webauthn) issuers (reserved), on-chain policy anchoring.

2. Policy document txpass/v1/policy

A JSON object. The fields below are the complete v1 set; unknown fields are rejected (policy_malformed). null is never allowed; optional fields are omitted.

FieldReqType / formatMeaning
vyes"txpass/v1/policy"Version tag. A v1 delegation verifier rejects it (different v), so it is never misread as a cert.
idyes32 lowercase hex (16 random bytes)Policy id. Unique per issuer.
issueryes66 lowercase hex, compressed secp256k1I, the entitlement owner.
issuer_typeyes"k1"As delegation v1. "p256-webauthn" reserved → policy_unsupported_issuer_type.
productyesnon-empty string ≤ 128Product slug (e.g. imprintai-api).
audyesnon-empty string, no *The facilitator issuer string exactly (https://txpass.app in production; published as delegation.audience in GET /v1/protocol).
nameyesstring, 1–64 chars after trim, no control charactersHuman label shown on /account and in the activity log. Signed so it can't be swapped.
scopeyesnon-empty array of { method, pathPattern }Route caveat; must be a subset of the product's declared routes (GET /v1/products). Required here (optional in v1 certs) so the page always states what agents can do.
plansnonon-empty array of non-empty stringsPlan pin, as v1.
receipt_txidnonon-empty stringReceipt pin, as v1.
max_session_ttlyesinteger seconds in [900, 3600]Cap on any minted session (D2).
capsyesobject, belowFacilitator-enforced limits.
caps.sessions_per_day_per_agentyesinteger in [1, 10000]Mints per admission per UTC day. Page default 200.
caps.max_agentsyesinteger in [1, 50]Active + pending admissions at once. Page default 5.
quota_shareno{ per_day?, per_month? }, non-negative integers, at least oneAdvisory, publisher-enforced per sub:dlg, i.e. per agent (as v1 §6.3).
agentsyesarray (may be empty) of distinct 66-hex keys, ≤ caps.max_agents, none equal to issuerInitial allowlist; admitted active on record.
enrollmentyes"none" | "codes"Whether code admits (§3) are accepted for this policy.
require_confirmnoboolean, default false when omittedIf true, code-joined agents start pending (D4). Allowlist and agent admits are always active. createPolicy omits it when false so the bytes stay minimal.
not_beforeyesunix secondsStart of validity.
expires_atyesunix seconds, > not_before, expires_at − not_before ≤ 31536000 (365 days)End of validity, exclusive (D5). Page/SDK default not_before + 90 days.
sigyeslowercase hex DER ECDSAIssuer signature (§4).

A policy is immutable. Changing anything (scope, caps, window, require_confirm) means signing a new policy and revoking the old one; the page does this as one "Edit" flow and can carry admitted agents over by listing them in the new agents.

3. Admit document txpass/v1/admit

FieldReqType / formatMeaning
vyes"txpass/v1/admit"Version tag.
idyes32 lowercase hexGrant id, unique per issuer.
issueryes66 hexMust equal the policy's issuer.
policyyes32 hexThe policy id.
agentone of66 hex, ≠ issuerApprove a known key (admitted active).
code_hashone of64 lowercase hex = sha256(UTF-8 code)Enrollment code grant. Policy must have enrollment: "codes".
expires_atyesunix secondsThe grant must be recorded and (for codes) redeemed before this. For codes: default now + 15 min, max now + 24 h at record time. For agent: must be ≤ the policy's expires_at; it bounds recording only, not the admission's life.
sigyeslowercase hex DER ECDSAIssuer signature (§4).

Exactly one of agent / code_hash (both or neither → admit_malformed). An admission never outlives its policy.

4. Canonical bytes and signing

Signed bytes = UTF-8 of canonicalize(document without "sig"), with canonicalize exactly as delegation-v1 §3: keys sorted by UTF-16 code unit recursively, no whitespace, JSON.stringify strings, safe integers only, arrays in order, null rejected, undefined dropped. Booleans (require_confirm) serialize as true/false. The output equals RFC 8785 (JCS) for this value domain.

Signing (BRC-100 wallet):

wallet.createSignature({
  data: Array.from(new TextEncoder().encode(canonicalize(payload))),
  protocolID: [2, 'txpass'],
  keyID: 'v1 policy',   // or 'v1 admit' for admit documents
  counterparty: 'anyone',
}, 'txpass.app')

Verification (anyone, offline): new ProtoWallet('anyone').verifySignature({ data, signature: hexToBytes(sig), protocolID: [2,'txpass'], keyID, counterparty: doc.issuer }).

Separate key ids (v1 policy, v1 admit, distinct from v1 delegate, v1 auth, v1 reveal) give domain separation: a signature over one document type can never verify as another even if bytes were somehow crafted to collide, and a wallet can show a distinct permission prompt ("allow Txpass agent policies"). The v tag gives a second, independent separation.

5. Enrollment codes

  • Format: txp- + 26 characters of RFC 4648 base32 (lowercase, no padding) from 130 bits of CSPRNG output (≥ 128 bits). Example shape: txp-k3v… (26 chars). Codes are compared exactly after trimming whitespace and lowercasing.
  • Generated client-side (generateEnrollmentCode() in @txpass/client, WebCrypto). The wallet signs an admit with only code_hash; the facilitator never sees the code until redemption. The page shows the code once.
  • Enrollment string (what the user pastes into an agent): txpass:enroll?f=<facilitator base URL, URL-encoded>&p=<policy id>&c=<code>[&i=<issuer key>] e.g. txpass:enroll?f=https%3A%2F%2Ftxpass.app&p=9f…&c=txp-…&i=03…. Only c is secret. The optional i (added in implementation, §15) lets the agent pin the owner before redeeming; the account page always includes it. txpass-agent enroll '<string>' parses it, redeems, and stores { facilitator, issuer, policy_id } (issuer learned from the redeem response and pinned).
  • Redeem: POST /v1/policies/:id/enroll { "code": "txp-…", "issuer"?: "<I>" }, BRC-103 authenticated as the agent key A. The facilitator:
    1. rate-limits (§10); checks neither the policy (backstop lock) nor this client source (per-source lock) is locked;
    2. loads the policy (live, not revoked, enrollment: "codes");
    3. computes sha256(code), finds an unredeemed grant for this policy with that hash;
    4. checks grant expires_at > now (code_expired otherwise), A ≠ issuer, A not already admitted (agent_already_admitted), admission count < max_agents (policy_cap_reached);
    5. atomically UPDATE policy_grants SET redeemed_at = now, redeemed_by = A WHERE issuer = … AND id = … AND redeemed_at IS NULL and inserts the admission in the same transaction. Zero rows updated → code_already_used.
    6. admission status = pending if require_confirm, else active; logs code_redeemed; the issuer's /account shows the "new agent joined" banner with one-click Remove (and Confirm if pending).
  • Unknown hash → code_invalid (same response time as a hit; counts toward the locks). A redeemed code shows up for the legitimate agent as code_already_used, which is the signal that it leaked.

6. Minting and the JWT

POST /v1/session (BRC-103 as A), body { "product": "imprintai-api", "policy": "<policy id>", "issuer": "<I>", "ttlSeconds": 900 }. policy and delegation are mutually exclusive (session_request_malformed). Absent policy → today's code path byte-for-byte.

Check order (first failure wins):

  1. body shape; issuer 66 hex; policy exists for (issuer, policy) → else policy_not_found;
  2. stored policy signature re-verified (cached) → policy_bad_signature;
  3. aud = facilitator issuer → policy_audience_mismatch; product = body product → policy_product_mismatch;
  4. window: now < not_before → policy_not_yet_valid; now ≥ expires_at → policy_expired;
  5. not revoked → policy_revoked;
  6. admission for (issuer, policy, A): none → agent_not_admitted; removed → agent_removed; pending → agent_pending_confirmation;
  7. daily cap: policy_usage_daily.sessions < caps.sessions_per_day_per_agent → policy_cap_reached. One session is reserved atomically with the check and handed back if any later step (or the mint) fails, so only successful mints count toward the cap;
  8. scope ⊆ product routes → policy_scope_invalid (normally caught at record time; re-checked in case product config shrank);
  9. product allow_delegation → delegation_not_allowed (same flag as v1);
  10. I's live entitlement for product, honouring plans / receipt_txid → the existing entitlement errors (402 entitlement_required).

JWT claims: iss, aud, sub = I, act: { sub: A }, dlg = <admission id>, pol = <policy id> (new, additive), scope = policy scope, quota_share if set, iat, exp, jti. TTL = min(requested or 900, 3600, max_session_ttl, policy expires_at − now, entitlement expiry).

  • Admission ids are 32 random hex, generated by the facilitator, unique per issuer across both policy_admissions.id and delegations.id (checked at insert), so dlg alone identifies the grant for publishers.
  • Publishers need no change: verifySessionJwt requires act.sub and dlg and ignores unknown claims; pol is only for display and introspection. Publishers enforcing quota_share by sub:dlg get a per-agent cap.

7. Revocation, removal, confirmation and introspection

  • Revoke policy: POST /v1/policies/:id/revoke { reason? } (as I). New mints → policy_revoked. Pending grants become unusable.
  • Remove agent: POST /v1/policies/:id/agents/:agent/remove (as I). Status removed, mints → agent_removed. Re-admitting the same key needs a new admit (a new admission id, so old tokens stay dead in introspection).
  • Confirm agent: POST /v1/policies/:id/agents/:agent/confirm (as I), only for pending → active; otherwise admission_not_pending.
  • Introspect (POST /v1/session/introspect): tokens carrying pol are checked against policy and admission after the existing signature/exp checks. Inactive answers are { "active": false, "reason": "policy_revoked" | "policy_expired" | "agent_removed" | "agent_pending_confirmation", "dlg": "…", "pol": "…" }. Active answers add "pol". Local verifiers honour tokens until exp (≤ max_session_ttl), as v1.

8. Routes

All BRC-103 authenticated; JSON bodies; errors are { error, message } with the codes in §9. "I" = caller must be the policy issuer.

RouteCallerBody / querySuccess
POST /v1/policiesI{ policy: {…signed doc…} }201 { policy, status, agents: [admissions] }; idempotent re-post of the identical doc → 200
GET /v1/policiesI?product=&include_inactive=1200 { policies: [{ policy, status, agents, counts: { sessions_today, sessions_30d } }] }
GET /v1/policies?role=agentA—200 { policies: [{ issuer, policy_id, name, product, scope, max_session_ttl, expires_at, aud, status, admission: { id, status } }] } (no other agents' keys)
GET /v1/policies/:idI—200 one issuer view (as in the list) plus open_codes: [{ grant_id, created_at, expires_at }] (added in implementation)
GET /v1/policies/:id/agentsI—200 { policy_id, agents: [admissions] } (added in implementation)
POST /v1/policies/:id/admissionsI{ admit: {…signed doc…} }201 { grant_id, kind: "agent"|"code", admission? }
POST /v1/policies/:id/enrollA{ code, issuer? }201 { issuer, policy_id, name, product, scope, max_session_ttl, expires_at, aud, admission: { id, status } }
POST /v1/policies/:id/agents/:agent/removeI—200 { admission }
POST /v1/policies/:id/agents/:agent/confirmI—200 { admission }
POST /v1/policies/:id/revokeI{ reason? } (≤ 200 chars)200 { policy_id, revoked_at }; idempotent
GET /v1/policies/:id/eventsI?before=&limit= (≤ 200)200 { events: [...] } newest first
POST /v1/sessionA{ product, policy, issuer, ttlSeconds? }as today, plus pol, policy_expires_at, sessions_today, sessions_per_day

The issuer list and single views also carry created_at, revoked_at?, revoke_reason?, locked_until? (only while locked), and per admission last_used_at? and sessions_today.

Record-time checks for POST /v1/policies: shape, signature, issuer = caller, aud, product configured, scope ⊆ product routes, window (not expired; lifetime ≤ 1 year), allow_delegation on, id unused by this issuer. Allowlisted agents are inserted as active admissions (via 'allowlist'). For POST …/admissions: shape, signature, issuer = caller = policy.issuer, policy live, code_hash only if enrollment: "codes", code expiry ≤ 24 h, agent admits respect max_agents.

A caller who is not the issuer gets 404 policy_not_found (not 403), so policy ids can't be probed for existence.

9. Error codes

CodeHTTPWhen
policy_malformed / admit_malformed400shape, unknown field, null, bad hex, both/neither agent/code_hash
policy_bad_signature / admit_bad_signature400signature does not verify
policy_unsupported_issuer_type400p256-webauthn
policy_forbidden403signed issuer ≠ caller on record
policy_audience_mismatch / policy_audience_wildcard_not_allowed400aud wrong / contains *
policy_product_mismatch400body product ≠ policy product
policy_scope_invalid400scope not a subset of product routes
policy_lifetime_too_long400expires_at − not_before > 31536000
policy_not_found404unknown, or caller is not the issuer
policy_not_yet_valid / policy_expired403window
policy_revoked403revoked
policy_cap_reached429daily session cap, or max_agents on admit/enroll
policy_locked429too many failed code redemptions (§10); body scope: "source" (this client source) or "policy" (backstop), plus locked_until, retry_after
agent_not_admitted403no admission for A
agent_removed403admission removed
agent_pending_confirmation403require_confirm and not yet confirmed
agent_already_admitted409enroll/admit for a key already active or pending
admission_not_pending409confirm on a non-pending admission
enrollment_disabled400code admit on enrollment: "none"
code_invalid400no grant with that hash
code_already_used409grant already redeemed
code_expired410grant expires_at passed
delegation_not_allowed403product disallows delegation (shared with v1)
session_request_malformed400POST /v1/session with both policy and delegation, or issuer not 66 hex (added in implementation)
policy_id_in_use409POST /v1/policies with an id this issuer already used for a different document (added)
admit_id_in_use409admit id already used by this issuer for a different grant (added)
admit_policy_mismatch400admit's issuer/policy ≠ the policy in the URL (added)
admit_expired410admit expires_at already passed at record time (added)
admit_expiry_too_long400admit expires_at more than 24 h after record time (added)
code_limit_reached42920 unredeemed, unexpired code grants already outstanding (added)
policy_limit_reached42950 live policies for this issuer (added)

10. Limits

LimitValue
Policy lifetime≤ 365 days (31,536,000 s); default 90 days
max_session_ttl900–3600 s
caps.max_agents1–50 (default 5); counts active + pending
caps.sessions_per_day_per_agent1–10,000 (default 200); UTC day
Code grant expirydefault 15 min, max 24 h
Agent admit expirydefault 1 h, max 24 h at record time (§15)
Code entropy≥ 128 bits (130 generated)
Outstanding unredeemed code grants per policy≤ 20
Live policies per issuer≤ 50
Document size≤ 8 KiB canonical bytes
Enroll rate limitper IP 10/min and per identity 10/min (new enroll bucket); 30/hour per (policy, client source); 300/hour per policy as a backstop (rate_limited, scope: "policy_source" / "policy")
Per-source lock10 failed redemptions (code_invalid/code_expired/code_already_used) for one policy from one client source within 1 h → that source gets policy_locked (scope: "source") until the oldest of those 10 is an hour old; other sources are unaffected. Client source = policy id + client IP (IPv6 /64) as resolved by the rate limiter (TXPASS_TRUST_PROXY); stored only as a truncated SHA-256 (detail.src), never returned by the events API
Policy lock (backstop)100 failed redemptions for one policy from all sources within 1 h → the whole policy is locked for code joins for 1 h (locked_until, scope: "policy")
Both lockslogged as code_redeem_failed / policy_locked (detail.scope), shown on /account; admitted agents keep minting
Other writesdelegation_write bucket; reads read bucket

11. Storage (Postgres; the memory store mirrors it for tests)

As implemented (POLICY_SCHEMA_SQL in apps/facilitator/src/policy-store.ts, applied idempotently on start-up; additive, no existing table changes). All timestamps are unix seconds in BIGINT, like every other facilitator table (the draft said timestamptz).

agent_policies (issuer text, id text, product text, aud text, name text, doc jsonb, max_session_ttl int,
  not_before bigint, expires_at bigint, created_at bigint, revoked_at bigint, revoke_reason text,
  locked_until bigint, PRIMARY KEY (issuer, id))
  CREATE INDEX ON agent_policies (id);
policy_admissions (issuer text, id text /* = dlg */, policy_id text, agent text,
  via text CHECK (via IN ('allowlist','admit','code')), grant_id text,
  status text CHECK (status IN ('active','pending','removed')),
  admitted_at bigint, confirmed_at bigint, removed_at bigint, last_used_at bigint,
  PRIMARY KEY (issuer, id))
  -- one live admission per agent per policy:
  CREATE UNIQUE INDEX ON policy_admissions (issuer, policy_id, agent) WHERE status <> 'removed';
  CREATE INDEX ON policy_admissions (agent);
policy_grants (issuer text, id text, policy_id text, agent text NULL, code_hash text NULL, doc jsonb,
  expires_at bigint, created_at bigint, redeemed_at bigint, redeemed_by text, PRIMARY KEY (issuer, id))
  CREATE UNIQUE INDEX ON policy_grants (policy_id, code_hash) WHERE code_hash IS NOT NULL;
policy_usage_daily (issuer text, admission_id text, day date, sessions int, PRIMARY KEY (issuer, admission_id, day))
policy_events (seq bigserial PRIMARY KEY, issuer text, policy_id text, at bigint, type text, agent text, detail jsonb)
  CREATE INDEX ON policy_events (issuer, policy_id, at DESC);
  -- types: created, agent_admitted, code_created, code_redeemed, code_redeem_failed, policy_locked,
  --        agent_confirmed, cap_hit, agent_removed, revoked

Single use is enforced by the conditional UPDATE … WHERE redeemed_at IS NULL in the same transaction as the admission insert. The daily cap uses INSERT … ON CONFLICT DO UPDATE SET sessions = sessions + 1 WHERE sessions < cap RETURNING sessions. max_agents and the outstanding-code limit take SELECT … FOR UPDATE on the policy row; the live-policy limit takes a per-issuer advisory lock. The session_minted_daily rollup event from the draft is not written: policy_usage_daily already holds the counts.

12. Security notes

  • Authority and audit, not trustlessness. The facilitator holds the JWT key and is trusted to mint honestly in any design. What policies add: every session traces to a document I signed, any compliant facilitator can re-verify the chain, and Txpass never holds user keys or sees codes before redemption.
  • Policy id and URL are not credentials. Minting needs an admitted key and a valid BRC-103 signature by it.
  • Leaked code: single use, short-lived, hash-only storage, rate limits and lock; a wrong-key redemption shows as a banner with one-click Remove; the legitimate agent sees code_already_used. Policies that want no window at all set require_confirm (D4).
  • Stolen agent key: remove the agent; tokens die within max_session_ttl. Proof-of-possession sessions remain future hardening (delegation-v1 §10).
  • Page compromise: strict CSP, no third-party scripts, and a plain-words review (agents, scope, window, caps) before every wallet prompt.
  • Privacy: GET /v1/policies?role=agent never reveals other agents' keys; events are issuer-only.

13. Test vectors (frozen)

Source of truth: packages/verify/test/policy.test.ts (it asserts every value below). Signatures use protocolID [2,'txpass'], counterparty anyone, RFC 6979 nonces, so they are deterministic: createSignature with a ProtoWallet over the listed private key reproduces them. Verification derives the BRC-42 child key from issuer, so the signature does not verify against the raw issuer key.

ItemValue
Issuer private key (I)1111 (decimal scalar) → 0228b7f3a019749cce6fc677afa8fae72ec10e811ed4b04e1963143cef87654b75
Agent admitted by key (A)private 2222 → 0271b357df56cfc77291b75e5f550d72687c1bef84ff9eadb4946eb55516a89d6f
Allowlisted agentprivate 3333 → 034cecb33a915e2c3b189eb6ee45fa4eb3eddeff09729945c600f893bd4381b294
Enrollment codetxp-abcdefghijklmnopqrstuvwxyz
sha256(code)d0fd63c02534836b54643b72c1ebfcaade4b71bf047ebf0e45bb9e8b4872da0e

Policy (keyID v1 policy); canonical bytes:

{"agents":["034cecb33a915e2c3b189eb6ee45fa4eb3eddeff09729945c600f893bd4381b294"],"aud":"https://facilitator.example","caps":{"max_agents":5,"sessions_per_day_per_agent":200},"enrollment":"codes","expires_at":1797776000,"id":"00112233445566778899aabbccddeeff","issuer":"0228b7f3a019749cce6fc677afa8fae72ec10e811ed4b04e1963143cef87654b75","issuer_type":"k1","max_session_ttl":1800,"name":"My coding agents","not_before":1790000000,"product":"imprintai-api","require_confirm":true,"scope":[{"method":"POST","pathPattern":"/api/imprint"},{"method":"GET","pathPattern":"/api/status/:jobId"}],"v":"txpass/v1/policy"}
  • SHA-256: 4e93468ee3a18ca337886d6b84d53f47973021d0cecc730ca477be97d23f8111
  • sig: 30440220743d436b65d42722581a7beadd0aa7b4b85620e8e551bfca37806785858f5bad02203196e21c0e0cd3bf7da8b822e9a16f8b0d8928765fffc94f31e17c47e1a39cdf

Admit, code grant (keyID v1 admit); canonical bytes:

{"code_hash":"d0fd63c02534836b54643b72c1ebfcaade4b71bf047ebf0e45bb9e8b4872da0e","expires_at":1790000900,"id":"0123456789abcdef0123456789abcdef","issuer":"0228b7f3a019749cce6fc677afa8fae72ec10e811ed4b04e1963143cef87654b75","policy":"00112233445566778899aabbccddeeff","v":"txpass/v1/admit"}
  • SHA-256: ff235fdab239d88b2cfb48ff339fe9fd92be106a78c5a955adc92fe6314c1b69
  • sig: 30450221008915dc714b572a16b7ed90e922be8ba42c7050df4ee137c6e429bd6d85056c380220520274a30dffc77bf3056248f24f5a3fb4500fc13e25b3e34a21b3d859e575d6

Admit, agent key (keyID v1 admit); canonical bytes:

{"agent":"0271b357df56cfc77291b75e5f550d72687c1bef84ff9eadb4946eb55516a89d6f","expires_at":1790003600,"id":"fedcba9876543210fedcba9876543210","issuer":"0228b7f3a019749cce6fc677afa8fae72ec10e811ed4b04e1963143cef87654b75","policy":"00112233445566778899aabbccddeeff","v":"txpass/v1/admit"}
  • SHA-256: 7090715aa1eecd4b60c4e275e2538239903c5689ba58e9053ca23937d5af9f6b
  • sig: 3045022100b8003708932d1129e28d756e5361d581fbee41493b2d7b14513f5e067e54619302203f42db93688917273a5b7916e225f8dfb463f904c0b054a5f721ec0ace5de097

Negative vectors (policy above, one change each): extra field "x":1 → policy_malformed; any field null → policy_malformed; expires_at = not_before + 31536001 → policy_lifetime_too_long; max_session_ttl: 899 → policy_malformed; an admit with both agent and code_hash → admit_malformed; one flipped byte in the policy → policy_bad_signature; the code admit checked against another policy id → admit_policy_mismatch.

15. Implementation notes (gaps this spec left open, and how they were settled)

  1. Timestamps: BIGINT unix seconds, not timestamptz (matches every other facilitator table; §11).
  2. Extra read routes: GET /v1/policies/:id (with open_codes) and GET /v1/policies/:id/agents, for the account page and support (§8).
  3. Extra error codes: session_request_malformed, policy_id_in_use, admit_id_in_use, admit_policy_mismatch, admit_expired, admit_expiry_too_long, code_limit_reached, policy_limit_reached (§9).
  4. Enroll issuer hint and i=: the enrollment string may carry the issuer key and POST …/enroll accepts issuer. With a hint the policy is looked up for that issuer only; a wrong hint is answered 404 policy_not_found, the same as an unknown id (guessing a 128-bit policy id is infeasible, so this reveals nothing useful). Without a hint the facilitator finds the policy by id. The agent pins the issuer it learns.
  5. Agent-key admits expire within 24 h of recording (default 1 h), the same bound as code grants. The spec only required ≤ policy expiry; a short bound keeps a signed-but-unsent approval from lingering.
  6. Agent-key approvals are active even when require_confirm is set, as §3 says: approving a specific key is the confirmation. require_confirm only affects code joins.
  7. Daily cap counts successful mints only. The check stays at step 7 of §6 and reserves one session atomically (INSERT … ON CONFLICT … WHERE sessions < cap), so concurrent mints can never exceed the cap; if a later check or the mint fails (e.g. 402 issuer_no_entitlement), the reservation is released (sessions = sessions − 1, never below 0). last_used_at is set only on success.
  8. The issuer cannot enroll into their own policy: POST …/enroll by the issuer key → 403 policy_forbidden.
  9. Lock scope: a stranger who knows a policy id must not be able to block code joins. The 10-failures/1-hour lock is per (policy id, client source), using the trusted-proxy client IP the rate limiter already uses; a per-policy backstop locks all sources only after 100 failures in an hour. The per-policy enroll rate limit is split the same way (30/hour per source, 300/hour per policy). A distributed attacker with ≥ 10 addresses can still reach the backstop; that pauses code joins for an hour, while admitted agents and key approvals keep working.
  10. Code-grant lookup: grants are found by (policy_id, code_hash); the unique index is global (not per issuer), which is safe because policy ids are random 128-bit values and the admit must also match the policy's issuer.
  11. The admin health (GET /v1/admin/health) reports policyStore ("memory" or "postgres"), next to store and delegationStore: the backend name only, no query and no secrets. (Public GET /health is only { ok, version }.) Policy storage follows DATABASE_URL like the delegation store.
  12. Joining a key-approved policy: an agent whose key was approved by an admit has no code, so txpass-agent policy enroll --policy <id> reads GET /v1/policies?role=agent (which now also returns aud) and saves the membership.
  13. Revocation latency: as delegation v1, removal and revocation stop new mints immediately; tokens already issued stay valid until exp (≤ max_session_ttl, 15–60 min) at publishers that verify locally. Txpass agents introspect before every write, so a removed Txpass agent stops at once; publishers that need instant cut-off call introspect.

16. Agent-initiated connect (device-authorization style)

This is the primary way an agent joins. The one-time code (§5) stays as the secondary option; one-off delegation stays under Advanced. Built like the OAuth device flow (RFC 8628) and "sign a TV into Netflix": the agent starts, the human approves in the browser.

16.1 Flow

  1. Agent: txpass-agent connect <product> [--scopes "POST /api/imprint,GET /api/status/:jobId"] [--scope S]… [--label L] [--facilitator URL]. The agent creates its key if needed (as enroll does) and calls POST /v1/connect/requests, BRC-103 signed by A. The facilitator answers a short user code and the approval link https://<facilitator>/account/connect?r=<id>, which the CLI prints (it only ever prints a link on the facilitator origin it is talking to). Without --scopes the CLI first reads the product's recommended_scopes from GET /v1/products and requests exactly those (printing that it did and how to ask for more); only when the product has none (or the catalog can't be read) does it omit scopes, i.e. request all routes, with a warning.
  2. Agent polls GET /v1/connect/requests/:id every interval seconds (3), longer after a 429 or Retry-After (RFC 8628 slow_down behaviour: +5 s, max 60 s), until approved, denied or expired. The request lives 15 minutes and is single use.
  3. Human opens the link on /account (connects a wallet if needed) and sees: Agent "‹label›" (‹key fingerprint›) wants to ‹scope labels› on ‹product› for ‹duration›, plus the user code with "approve only if this matches your terminal". They either

    • create a new policy inline (defaults: requested scopes ∩ product routes, all ticked, may only be narrowed; when the product has no recommended_scopes and the request covers every route, the screen shows a warning asking the human to untick what the agent doesn't need; 30-minute session window; 90-day expiry; 5 agents; 200 sessions/agent/day; enrollment: "none") whose agents allowlist names A → one signature (the policy) creates the policy and admits A (recorded via "connect", shown as "Connected"; other keys in the same allowlist stay via "allowlist"); or
    • add A to an existing live policy for the same product → one signature (a txpass/v1/admit with agent = A, §3). The screen says so when the existing policy grants more than the agent asked for.

    Deny needs no document signature (it is still a BRC-103 authenticated call).

  4. On approval the agent's poll returns the membership (the same fields as GET /v1/policies?role=agent); the CLI saves policy.json, sets credential = policy, pins the issuer, and can mint at once with the existing POST /v1/session { product, policy, issuer } path (§6). An approval by a different wallet than an already pinned issuer is refused locally unless --allow-new-issuer.

No new signed document types: approvals are ordinary policy / admit documents, verified by the same code as POST /v1/policies and POST /v1/policies/:id/admissions (same checks, same errors, same events).

16.2 Routes

All BRC-103 authenticated. Errors are { error, message }.

RouteCallerBodySuccess
POST /v1/connect/requestsA{ product, scopes?: [{method, pathPattern}] | ["METHOD /path"], label? }201 { id, user_code, verification_uri, verification_uri_complete, product, scope, label?, expires_at, interval }
GET /v1/connect/requests/:idA (the requester)—200 { id, status, user_code, expires_at, interval } (+ Retry-After while pending); when approved also membership: { issuer, policy_id, name, product, scope, max_session_ttl, expires_at, aud, status, admission: { id, status } }
GET /v1/connect/requests/:idany other wallet—pending: 200 { id, status, agent, label?, product, scope, user_code, created_at, expires_at }; resolved: only { id, status } (+ policy_id for the approver)
POST /v1/connect/requests/:id/approveI{ policy } (signed, agents ∋ A, same product, scope ⊆ requested) or { policy_id, admit } (I's own live policy, same product, admit agent = A)200 { id, status: "approved", policy_id, admission: { id, status }, result }
POST /v1/connect/requests/:id/denyany wallet—200 { id, status: "denied" }
  • id: 32 hex (128 random bits). user_code: 8 characters from the RFC 8628 consonant alphabet BCDFGHJKLMNPQRSTVWXZ, shown XXXX-XXXX (≈ 34.6 bits). scopes omitted or empty = all product routes; any route outside the product → connect_scope_invalid (the request is refused, not silently intersected, so the agent learns its mistake). At most 20 scopes.
  • label: optional, untrusted display text. The facilitator sanitizes it (NFC; control, format/bidi, zero-width, private-use and separator characters replaced; whitespace collapsed; ≤ 48 code points). The page renders it only with textContent, quoted, never as a link.

Errors: connect_request_malformed (400), connect_product_unknown (400), connect_scope_invalid (400), connect_pending_limit (429), connect_capacity (503, Retry-After: 60), connect_request_not_found (404; unknown, purged, or bad id), connect_request_expired (410), connect_request_not_pending (409, with status), connect_agent_mismatch (400), connect_product_mismatch (400), connect_scope_exceeds_request (400), connect_self_approval (403), delegation_not_allowed (403), policy_not_found (404); plus every policy/admit error of §9 from the shared verification (policy_bad_signature, policy_forbidden, admit_bad_signature, policy_revoked, policy_cap_reached, …). A failed approval puts the request back to pending, so the human can retry.

16.3 Security

  • The link grants nothing. Approval needs a policy or admit signed by the approving wallet; the request id is only a locator. Only A (the key that signed the create call) can read the approved membership.
  • User code on both screens. The human matches the code in the terminal with the code on the page, which defeats a phishing link that points at an attacker's request.
  • Single use: approval claims the row atomically (pending → approving, only while unexpired), runs the shared create/admit, then completes (approved) or releases (pending). Two approvals can never both succeed; a claim older than 60 s (crashed approver) may be re-claimed. Deny works only on pending, unexpired rows.
  • Limits: rate-limit bucket connect on create (per trusted-proxy client IP 10/min, per identity 5/min; env TXPASS_RATE_LIMIT_CONNECT_PER_IP / _PER_IDENTITY); at most 3 pending requests per agent key and 10,000 pending in total (backstop against key-rotation floods). Polling uses the read bucket (60/min per IP, 30/min per identity), which a 3-second interval stays under.
  • Minimal storage: a row holds only the agent key, sanitized label, product, requested scope, user code, status and timestamps, plus issuer / policy id / admission id once approved. No IP address, no user agent. Rows are purged one hour after expiry (opportunistically, at most once a minute, on create).
  • Self-approval refused: the agent's own key cannot approve its request (and the policy code already refuses an issuer in its own allowlist).

16.4 Storage

connect_requests (Postgres, created on start-up with CREATE TABLE IF NOT EXISTS, additive like the policy tables; the memory store mirrors it):

id TEXT PRIMARY KEY, agent TEXT NOT NULL, label TEXT, product TEXT NOT NULL, scope JSONB NOT NULL,
user_code TEXT NOT NULL, status TEXT NOT NULL,            -- pending | approving | approved | denied
created_at BIGINT NOT NULL, expires_at BIGINT NOT NULL, claimed_at BIGINT, resolved_at BIGINT,
issuer TEXT, policy_id TEXT, admission_id TEXT
-- indexes: (agent, status, expires_at), (expires_at)

expired is never stored: an unresolved row past expires_at reads as expired. Create runs under a per-agent advisory lock so the pending cap holds under concurrency. GET /v1/admin/health reports connectStore ("memory" or "postgres"), backend name only.

16.5 Decisions

  1. One signature for "approve with a new policy", by putting A in the new policy's agents allowlist; one signature (an admit) for "add to existing". No new document type.
  2. No terminal QR code: there is no dependency-free QR encoder in the repo, and the link plus an 8-character code is short enough to type. Can be added later.
  3. Requested scopes are validated, not intersected, on create; a new policy may only narrow them (connect_scope_exceeds_request). An existing policy may grant more than requested; the approval screen lists the extra routes before the signature. Default request = the product's recommended_scopes: the CLI asks for the publisher's recommended set unless --scopes is given; "all routes" is only the fallback for products without a recommendation, and then the approval screen warns. The server-side default for an omitted scopes stays "all product routes" (older CLIs keep working).
  4. New policies from the connect screen use enrollment: "none" (no codes) and a fixed default name ("Connected agents", editable): the untrusted label never ends up inside a signed document. More agents can join the same policy later through connect ("existing policy") or by key.
  5. Deny is open to any authenticated wallet holding the link (no document signature). Worst case a third party who learned the 128-bit id denies a request, and the agent runs connect again.
  6. Poll rate is enforced through interval / Retry-After and the existing read bucket, not a separate slow_down error.
  7. Connect reuses the "Connected agents" policy by default, so repeated connects do not pile up identical policies. The approval screen pre-selects "An existing policy" when the issuer has a policy for the request's product that is: named "Connected agents" (case/whitespace-insensitive; other names are never auto-picked), active (not revoked, expired or not-yet-valid) with at least 7 days left, not full (live agents < max_agents), not already holding this agent live (a removed agent is re-admitted as a new admission), and no wider than the request (every policy route was asked for). Ties go to the one that expires last. Otherwise it falls back to a new policy, named "Connected agents 2", "… 3" if the base name is taken. The user can always switch. Nothing changes on the wire: reuse is the existing {policy_id, admit} approval, and the one wallet signature is an admit that binds exactly that policy id and agent key.
  8. Policy names are unique per issuer + product among active policies. POST /v1/policies (and a connect approval with a new policy) answers 409 policy_name_in_use (with the clashing policy_id) when another active or not-yet-valid policy of the same issuer and product has the same name (trimmed, whitespace collapsed, case-insensitive). Re-posting the same signed policy (same id) stays idempotent; revoked/expired policies free the name. The account page checks first and asks for a different name. Duplicates created before this rule are left as they are.