Home/Docs/Protocol

Protocol spec

A faithful rendering of Writ v0.1 — the chain model, mandate schema, assertion format, verification flow, replay rules, and selective disclosure. The source of record is SPEC.md — raw markdown, and this page is its rendering.

The chain of agency #

KYC verifies a point. The passport verifies the chain: it resolves any agent action back to an accountable human.

chain
action ← agent      # keypair, runtime metadata
       ← mandate    # scope, limits, expiry, revocable
       ← principal  # KYC'd human or entity
       ← liability
Chain of agency: an action is signed by an agent, scoped by a mandate, issued by a principal, who bears liability.
Every verified action resolves left-to-right to an accountable party.

We are the neutral attestation layer — records, not rails. We never custody or move funds. Platforms integrate one middleware ("require KYA"); principals onboard once and issue scoped mandates to their agents; agents present signed assertions with every call.

Actors & objects #

ObjectIDWhat it is
Principalprn_A KYC'd human or entity that bears liability. KYC via a pluggable vendor interface (MVP: a sandbox stub shaped like Persona / Stripe Identity).
Agentagt_A registered agent identity: an Ed25519 keypair plus self-declared runtime metadata (name, model, framework). The key is held by the runtime; the public key is registered.
Mandatemnd_A signed, scoped, revocable grant from principal to agent. The heart of the system.
Platformplt_An integrator (brokerage, exchange, SaaS) with an API key, calling the Verify API.
Verificationvrf_An immutable, signed audit record of one allow/deny decision — co-signable by the platform from day one.

Mandate schema #

A mandate is the narrowest possible authority. Amounts are in minor units.

mandate.json
{
  "id": "mnd_…",
  "principal": "prn_…",
  "agent": "agt_…",
  "scopes": [{
    "action": "account.refill",
    "max_amount_per_tx": 100000,
    "max_amount_per_period": 250000,
    "period": "week",
    "currency": "USD",
    "platforms": ["plt_…"],
    "purpose": "trading-capital"
  }],
  "not_before": "…", "expires_at": "…",
  "status": "active | revoked",
  "issued_at": "…",
  "issuer_sig": "<JWS by passport issuance key>"
}

A scope for an action in the document.* namespace carries one extra field, document_classes, and its caps are read as liability caps — see Signature authority below.

Issuance model

MVP issuance is custodial: the passport signs mandates after the principal authorizes in the dashboard — the CA model. The documented upgrade path is principal-held keys / W3C Verifiable Credentials with identical semantics, so a standards migration is a serializer change, not a rewrite.

Assertion format #

On every call the agent sends header X-Passport: <JWS>, where the JWS is signed by the agent key over:

assertion payload
{
  "agent":    "agt_9f…",
  "mandate":  "mnd_tr7…",
  "action":   "account.refill",
  "amount":   50000,
  "currency": "USD",
  "platform": "plt_northbank",
  "nonce":    "3f9ac1d84e2b7a06",
  "iat":      1786500251
}

amount is in minor units; iat is unix seconds and doubles as the replay timestamp.

For an action in the document.* namespace the payload carries one more claim, document — present iff the action is a document action, absent otherwise. Either way round, a mismatch is context_mismatch. See Signature authority below.

Verification flow #

Sequence diagram: setup happens once — onboard, register the agent key, issue a mandate; on every call the agent signs an assertion, the platform posts it to /v1/verify, and Writ returns a decision, the resolved chain, and a signed receipt; allowed actions are executed and countersigned, denied ones return 403 with a reason.
Setup once, then one signed assertion + one verify round trip per call.
  1. Agent → Platform. A normal API call plus the X-Passport header.
  2. Platform → Passport. POST /v1/verify with the raw assertion and platform context. One round trip.
  3. Passport checks, in order: agent signature → mandate active / not expired / not revoked (a live check — revocation is instant) → scope covers action + amount + platform (and, for document.*, the document class) → cumulative period counters (we maintain them) → principal KYC status → sanctions screen (stub interface in MVP).
  4. Response — decision, reason, resolved chain, verification id, and a signed receipt:
verify response
{
  "decision": "allow | deny",
  "reason": "ok | mandate_revoked | per_tx_cap | period_cap |
             document_class | kyc_lapsed | unknown_agent | replay | …",
  "chain": {
    "principal": { "type": "individual", "country": "US",
                    "kyc": "verified", "accredited": true },
    "agent": { "id": "agt_…", "name": "treasury-bot", "runtime": "claude-code" },
    "mandate": { "id": "mnd_…", "remaining_this_period": 150000 }
  },
  "verification_id": "vrf_…",
  "receipt": "<JWS signed by passport>"
}

Replay rules #

The nonce and iat fields make each assertion single-use:

  • Timestamp window: iat (unix seconds) must be within ±120 seconds of the passport's clock.
  • Nonce: single-use. A replayed nonce inside the window is rejected with reason replay.

Signature authority (document.*) #

The same chain, asked about paper instead of money:

chain
signature ← agent      # keypair, runtime metadata
          ← mandate    # document classes, liability caps, expiry, revocable
          ← principal  # KYC'd human or entity
          ← liability

Any action beginning with document. is in the namespace; document.sign is the one in use. Writ attests the authority and signs the record of the decision. It never produces the signature — the e-signature platform still does that — and none of this is a qualified electronic signature (eIDAS/QES). UETA §14 and ESIGN already recognise contracts formed by electronic agents; agency law binds a principal only within the authority granted. Writ is the record of that authority.

Mandate scope. One optional field, document_classes, required on a document.* action and rejected (422) on any other. The vocabulary is closed: nda · msa · sow · order_form · dpa · other.

mandate scopesigned grant
{
  "action": "document.sign",
  "document_classes": ["nda", "order_form"],
  "max_amount_per_tx": 5000000,
  "max_amount_per_period": 20000000,
  "period": "month",
  "currency": "USD",
  "platforms": ["plt_…"],
  "purpose": "vendor-paperwork"
}

For a document scope the existing caps are read as liability caps: max_amount_per_tx is the per-document cap, max_amount_per_period the cumulative cap for the period. Same cap engine, same period counters, same per_tx_cap / period_cap reasons as payments. The spend counter is keyed by (mandate, action), so document.sign accrues its own liability counter beside a payment action's money counter on the same mandate.

The document block. The same object appears in the assertion, in the verify request, and in the receipt:

document
{
  "document_hash": "<lowercase hex sha256 of the document bytes>",
  "document_class": "nda",
  "counterparty": "Northbank Sandbox",
  "liability_minor": 2500000
}

The passport never receives the document — only its SHA-256. liability_minor is the same quantity a payment calls amount; an assertion carries it in both places and the two must agree.

Verify request. The body gains an optional document. amount is optional only when document is present — a signing call states its number once — and if both are sent they must be equal or the body 422s.

request body
{
  "assertion": "<JWS>",
  "action": "document.sign",
  "currency": "USD",
  "document": { "document_hash": "…", "document_class": "nda",
                "counterparty": "Northbank Sandbox", "liability_minor": 2500000 }
}

Receipt. The passport-signed receipt carries the document claim alongside the usual ones, so the record names the exact paper rather than "a signature". A document presented at verify that differs from the one the agent signed for is context_mismatch.

Reason enum. One reason joins the closed set — document_class: the mandate covers document signing, but not paper of this class. It sits between period_cap and kyc_lapsed, and is deliberately distinct from scope_not_found (no document authority at all) and from the cap reasons (right class, too much liability).

Selective disclosure #

The platform sees attributes — KYC level, country, accreditation flag — never the principal's identity documents, unless the mandate explicitly grants disclosure or a lawful request compels it. Subject access, correction, and dispute flows are first-class: FCRA-shaped by design, so regulation becomes a moat rather than a tax.

Co-signing #

After executing the action the platform may POST /v1/verifications/{id}/countersign. A record signed by both sides is nearly incontestable — the Evidence-Layer L2 law, implemented from day zero. See countersigning.

API surface (MVP) #

POST/v1/principals

Plus the sandbox KYC flow, which runs in the principal dashboard.

POST/v1/agents  ·  POST/v1/agents/{id}/rotate
POST/v1/mandates  ·  GET/v1/mandates/{id}  ·  POST/v1/mandates/{id}/revoke

GET /v1/mandates/{id} takes the principal's Bearer secret and returns the signed grant plus its decision history.

POST/v1/verify
POST/v1/verifications/{id}/countersign
GET/v1/verifications  ·  audit export

Accounts & sessions

POST/v1/auth/signup  ·  POST/v1/auth/login  ·  POST/v1/auth/logout  ·  GET/v1/auth/session
POST/v1/auth/magic-link  ·  GET/v1/auth/magic-link/consume?token=…

Sign in from an emailed link instead of a password. Live wherever a RESEND_API_KEY secret is configured, which production and staging both are; on a stack with no mail it answers 503 and names password sign-in as the alternative.

POST/v1/auth/password/forgot  ·  POST/v1/auth/password/reset  ·  POST/v1/auth/password/change

forgot takes {email} and always answers {sent:true, detail}, whether or not the address has an account. reset takes {token, password}. change takes {current_password, new_password}, is session-authed, and 401s on a wrong current password.

POST/v1/auth/verify-email  ·  GET/v1/auth/verify-email/consume?token=…

The first is session-authed and re-sends the confirmation link. Confirmation is asynchronous and gates nothing: the key works immediately, the quota is unaffected, and an unconfirmed address changes only a banner in the console.

Token lifetimes, all single-use

Sign-in link 15 minutes · reset link 60 minutes (requesting a second one retires the first) · confirmation link 7 days. They are purpose-scoped in the lookup predicate, so a confirmation token cannot be redeemed as a sign-in and a sign-in token cannot confirm an address — presented at the wrong door, a token is indistinguishable from one that never existed.

What a completed reset or change does

It ends every other signed-in session (an epoch on the account rides the signed cookie, so cookies minted before the change stop resolving on every device) while keeping the person who did it signed in. It does not touch API keys or principal secrets — those are separate credentials, and a deployed service keeps verifying straight through a password reset. A completed reset also confirms the email address, since clicking an emailed link demonstrates exactly the property confirmation is trying to establish.

POST/v1/account/claim/platform  ·  POST/v1/account/claim/principal

Bind an existing key or principal secret to an account; purely additive, the key/secret keeps working unchanged.

GET/v1/platforms/self/keys  ·  POST/v1/platforms/self/keys

Named API keys, up to 10 active per platform — plus PATCH /v1/platforms/self/keys/{keyId} to relabel one and POST /v1/platforms/self/keys/{keyId}/revoke to kill it.

Dashboards. Platform console (/platform): logged in is the primary path — your platforms, plan + usage, named-key management, the decision log with export, billing, and an account activity log; pasting a key still works as a read-only fallback for platforms with no account. Principal console (/principal): lists agents and mandates, registers agents, issues mandates through a form with a live plain-English restatement of the grant, one-click revoke, and a mandate detail view showing the signed grant (issuer_sig, verifiable offline against /v1/issuer/jwks) alongside its full decision history. A mandate issued in the console is byte-identical to one issued via POST /v1/mandates — same code, same signature.

The rest of the HTML surface is /, /signup, /login, /logout, /onboarding, and the three auth pages /forgot-password, /reset-password, /verify-email; the names people actually type redirect rather than 404 (/reset · /forgot · /password-reset/forgot-password, /sign-up · /register/signup, /sign-in · /signin/login, /console · /dashboard/platform, /permissions/principal, plus /docs and /pricing to the marketing site), as does a trailing slash on any page path.

SDKs

  • Platform middleware (@writhq/verify) — Express / Hono / Next middleware, requireKYA({ action }), ~10 lines, fails closed.
  • Agent SDK (@writhq/sdk) — holds the keypair, signs assertions, attaches headers. Plus an MCP server (@writhq/mcp) exposing passport_present / passport_status so Claude Code and any MCP-capable runtime can transact with a passport today.

MVP scope fence #

In: the full chain model with sandbox KYC and a stub sanctions screen; Cloudflare Workers + D1 (SQLite at the edge), the D1 primary in one region; the demo brokerage; the MCP server; the CLI; and audit export.

Out — documented, not built

Real KYC vendors · principal-held keys / W3C Verifiable Credentials · webhooks · multi-sig mandates · per-jurisdiction rulepacks · agent reputation scores. That's the L3–L5 climb.

Interop is rails-neutral by the law of the category: we interoperate with x402 / AP2 / ACP-style agentic-payment flows rather than compete — AP2's mandates and ours should map 1:1. The token format tracks W3C VC / JWS.