# Writ — Protocol & MVP Spec v0.1

**Product:** Writ · **Category:** KYA (Know Your Agent) · **One-liner:** Let verified agents transact.

**The problem.** Platforms receive API traffic from AI agents that want to do real economic actions — refill an account, place an order, subscribe to a fund. Today they block it as fraud, because no compliance regime can answer: *who committed this action?* Is it a human or an agent; if an agent, whose; under what authority; with whose liability?

**The product.** Writ resolves any agent action to its **chain of agency** — it issues passports to agents and verifies them on every call:

```
action ← agent (keypair, runtime metadata)
       ← mandate (scope, limits, expiry, revocable)
       ← principal (KYC'd human or entity)
       ← liability
```

KYC verified a point. The passport verifies the chain. Platforms integrate one middleware ("require KYA"); principals onboard once and issue scoped mandates to their agents; agents present signed assertions with every call. We are the neutral attestation layer — **records, not rails**: we never custody or move funds.

---

## 1. Actors and objects

| Object | ID prefix | What it is |
|---|---|---|
| Principal | `prn_` | KYC'd human or entity that bears liability. KYC via pluggable vendor interface (MVP: sandbox stub with the same interface shape as Persona/Stripe Identity). |
| Agent | `agt_` | A registered agent identity: Ed25519 keypair + self-declared runtime metadata (name, model, framework). Key held by the agent runtime; public key registered with us. |
| Mandate | `mnd_` | A signed, scoped, revocable grant from principal to agent. The heart of the system. |
| Platform | `plt_` | An integrator (brokerage, exchange, SaaS) with an API key, calling our Verify API. |
| Verification | `vrf_` | An immutable, signed audit record of one allow/deny decision, co-signable by the platform (counterparty co-signing from day one). |

## 2. Mandate schema

```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>"
}
```

Amounts in minor units. MVP issuance is custodial (we sign 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.

## 3. Presentation & verification flow

1. **Agent → Platform:** normal API call plus header `X-Passport: <JWS>`, where the JWS is signed by the **agent key** over `{ agent, mandate, action, amount, currency, platform, nonce, iat }` — plus `document` for a `document.*` action (§9). `iat` is **unix seconds**, and doubles as the replay timestamp: nonce + `iat` prevent replay (±120s window, nonce single-use).

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

2. **Platform → Passport:** `POST /v1/verify` with the raw assertion + platform context. One round trip.
3. **Passport checks:** agent signature → mandate active/not expired/not revoked (live check — revocation is instant) → scope covers action+amount+platform → cumulative period counters (we maintain them) → principal KYC status → sanctions screen (stub interface in MVP) .
4. **Response:**

```json
{
  "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>"
}
```

**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; regulation becomes moat).

5. **Co-signing:** platform may `POST /v1/verifications/{id}/countersign` after executing the action. A record signed by both sides is nearly incontestable — this is the Evidence-Layer L2 law, implemented from day zero.

## 4. API surface

Every live route, grouped by the credential that opens it: nothing, the admin token, a platform API key, a principal secret, a session cookie, or a Stripe webhook signature. (The two claim routes are the one place that wants two at once — a session *and* the key or secret being claimed.)

**Open**

- `GET /v1/health` · `GET /v1/issuer/jwks` (the issuance public key — verify any mandate or receipt offline)
- `POST /v1/signup` (key-only platform signup, per-IP capped) · `POST /v1/principals` (+ sandbox KYC, per-IP capped)

**Admin token**

- `POST /v1/platforms` (register/upsert a platform; new ones land on the unmetered `internal` plan) · `POST /v1/admin/usage` (set a month's usage counter, or a plan, by hand)

**Platform API key**

- `POST /v1/verify` · `GET /v1/verifications` (audit export; `?limit=` ≤ 1000, scoped server-side to the key's platform) · `POST /v1/verifications/{id}/countersign`
- `GET /v1/platforms/self` (plan, usage, billing state) · `POST /v1/platforms/self/rotate-key` (rotates whichever key authenticated) · `POST /v1/platforms/self/key` (put your own countersigning public key on file)
- `GET`/`POST /v1/platforms/self/keys` · `PATCH /v1/platforms/self/keys/{keyId}` · `POST /v1/platforms/self/keys/{keyId}/revoke` — up to 10 active named keys per platform
- `POST /v1/billing/checkout` · `POST /v1/billing/portal`

**Principal secret**

- `POST /v1/agents` (register public key) · `POST /v1/agents/{id}/rotate` (caller supplies the new `public_jwk`)
- `POST /v1/mandates` · `GET /v1/mandates/{id}` (the signed grant plus every decision made under it) · `POST /v1/mandates/{id}/revoke`
- `POST /v1/principals/{id}/kyc` · `GET /v1/principals/{id}/dashboard`

**Accounts and sessions** — the human layer above the credentials; an account owns platforms and principals but is not itself a credential.

- `POST /v1/auth/signup` (account + optionally its first platform) · `POST /v1/auth/login` · `POST /v1/auth/logout` · `GET /v1/auth/session`
- `POST /v1/auth/magic-link` · `GET /v1/auth/magic-link/consume?token=…` — emailed sign-in, single-use, 15 minutes
- `POST /v1/auth/password/forgot` (always answers `{sent:true}`, so it cannot enumerate addresses) · `POST /v1/auth/password/reset` (single-use token, 60 minutes; a second request retires the first) · `POST /v1/auth/password/change` (session + the current password)
- `POST /v1/auth/verify-email` (session; re-sends) · `GET /v1/auth/verify-email/consume?token=…` — single-use, 7 days. Confirmation is **asynchronous and gates nothing**: the key works immediately and the quota is unaffected.
- A completed reset or change ends **every other session** (an epoch on the account rides the signed cookie) and leaves API keys and principal secrets **untouched** — a deployed service keeps verifying straight through one. Tokens are purpose-scoped: a confirmation token cannot be redeemed as a sign-in, or the reverse.
- `POST /v1/account/claim/platform` · `POST /v1/account/claim/principal` — bind an existing key or secret to an account without changing it

**Session cookie** (the consoles' own routes; twins of the key-authed ones above)

- `POST /v1/account/platforms` · `GET /v1/account/platforms/{id}/verifications` · `GET`/`POST /v1/account/platforms/{id}/keys` · `PATCH`/`POST …/keys/{keyId}[/revoke]` · `POST /v1/account/platforms/{id}/billing/checkout|portal` · `GET /v1/account/audit`
- `POST /v1/account/principals` · `GET /v1/account/principals/{id}` · `POST /v1/account/principals/{id}/agents` · `POST /v1/account/principals/{id}/mandates` · `GET`/`POST /v1/account/mandates/{id}[/revoke]`

**Stripe signature**

- `POST /v1/billing/webhook` — plan flips; the signature is the auth, and a bad one is a 400

**HTML** — `/`, `/signup`, `/login`, `/logout`, `/forgot-password`, `/reset-password`, `/verify-email`, `/onboarding`, `/platform` (platform console: API keys, decision log, plan + usage, billing), `/principal` (permissions console: agents, mandates, one-click revoke, activity). Common misspellings redirect rather than 404 — `/reset` · `/forgot` · `/password-reset` · `/sign-up` · `/register` · `/sign-in` · `/signin` · `/console` · `/dashboard` · `/permissions` · `/docs` · `/pricing` — as does a trailing slash on any page path.

## 5. SDKs

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

## 6. The demo (this IS the pitch)

**"Northbank Sandbox"** — a fake brokerage with a real API, integrated via the middleware:

1. Principal signs up, passes sandbox KYC, registers agent `treasury-bot`, issues mandate: `account.refill` ≤ $1,000/tx, ≤ $2,500/week at Northbank.
2. Agent (via MCP in Claude Code, or the CLI) refills **$500 → ALLOW** — balance updates, receipt shows the full resolved chain.
3. Agent attempts **$2,000 → DENY (per_tx_cap)**.
4. Principal revokes the mandate in the dashboard; next attempt → **DENY (mandate_revoked)** within seconds.
5. Anonymous agent (no header) → **403 "KYA required"** with an onboarding link — the block-page that turns rejected traffic into leads.

## 7. Positioning & interop

- **Rails-neutral by law of the category:** interoperate with x402 / AP2 / ACP-style agentic-payment flows rather than compete — AP2's mandates and our mandates should map 1:1; we are the KYA/identity layer any rail can call. Token format tracks W3C VC/JWS so a standards migration is a serializer change, not a rewrite.
- **Where we hunt first:** platforms losing agent-originated volume they'd love to accept — trading/prop platforms, exchanges/on-ramps, ad-budget top-ups, API-credit refills, marketplaces. The refill/deposit primitive generalizes everywhere.
- **Regulatory posture:** attestation layer only. No custody, no money transmission, KYC through licensed vendors, subject rights built in. We sell *decisions and records*, not movement of value.
- **Evidence Layer fit:** this is L0 (identity spine). Verifications are L1–L2 records born attested and co-signed. Iris sessions and payment records signed by passported agents become evidence with a resolvable author — same company thesis, three shelves.

## 8. MVP scope fence

**In:** everything above with sandbox KYC + stub sanctions; Cloudflare Workers + D1 (SQLite at the edge) with the D1 primary in a single region; demo brokerage; MCP server; CLI; audit export. Plus signature authority (§9).
**Out (documented, not built):** real KYC vendors, principal-held keys/VCs, webhooks, multi-sig mandates, per-jurisdiction rulepacks, agent reputation scores (that's the L3–L5 climb).

## 9. Signature authority — the `document.*` namespace

The same chain answers a second question. Instead of *may this agent move $500*,
a counterparty asks *may this agent put its principal's name on this document*:

```
signature  ←  agent  ←  mandate  ←  principal (KYC'd)  ←  liability
```

**Boundary:** Writ attests the **authority** and returns a signed record of that
decision. It never produces the signature — the e-signature platform still does
that. Nothing here is a qualified electronic signature (eIDAS/QES) and it does
not try to be. The legal anchor is ordinary: UETA §14 and ESIGN already
recognise contracts formed by electronic agents, and agency law already binds a
principal only within the authority actually granted. Writ is the record of that
authority.

**Scope.** A `document.*` scope adds one field and re-reads the caps:

```json
{
  "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"
}
```

- `document_classes` — the closed vocabulary `nda · msa · sow · order_form ·
  dpa · other`. Required for `document.*` actions (a scope that names no class
  could only ever deny, so issuance rejects it 422) and rejected on any other
  action.
- `max_amount_per_tx` — the **per-document liability cap**.
- `max_amount_per_period` — the **cumulative liability cap** for the period.

The caps are the payment cap engine unchanged: the same comparisons, the same
server-side counters, the same `per_tx_cap` / `period_cap` reasons. The `spend`
counter is keyed by (mandate, action), so `document.sign` gets its own liability
counter beside `account.refill`'s money counter. Signature authority is metered
in exposure rather than money; nothing else about it is new.

**Presentation.** The assertion and the verify request each carry a `document`
block; the platform's copy must equal what the agent signed for, or the decision
is `context_mismatch`:

```json
{
  "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 digest. `liability_minor`
and the assertion's `amount` are the same quantity in two places (the caps meter
`amount`, the receipt states `liability_minor`); if they disagree, nothing was
agreed and the decision denies. On the wire `amount` may be omitted from a
verify body that carries a document.

**Reasons.** One addition to the closed set: **`document_class`** — the mandate
covers document signing, but not paper of this class. No document authority at
all is still `scope_not_found`; a document too large is `per_tx_cap`; too much
cumulative exposure is `period_cap`.

**Record.** The document block is bound into the signed receipt and stored on
the verification, so the decision log names the exact paper — class, hash,
counterparty, liability — for allows and denials alike.
