{
  "openapi": "3.1.0",
  "info": {
    "title": "nouli",
    "summary": "Proof of Existence for agents — the external witness, paid per call via x402",
    "description": "Agents are the only actors in the economy that can't testify for themselves — a self-attested log is worthless because whoever can rewrite the log can rewrite the hash. nouli is the external witness: it binds data to the public drand quicknet beacon (a time-source nobody, including the agent, can forge retroactively — 3-second granularity where a Bitcoin block is ~10 minutes) and returns signed, hash-chained receipts a stranger can verify with zero trust in nouli. Proof of Existence is the product, one engine: POST /v1/anchor is the headline — instant, verifiable proof that data existed at or before the cited round ($0.05, your bytes cited digest-only; agents journal milestones, content provenance, commit-before-dispute, compliance logging, and raffle/sweepstakes/tournament/audit operators prove a draw was fair); GET /v1/flip is the impartial tie-breaker that comes with proof ($0.02 — the receipt IS the proof of fairness, not a courtesy artifact); GET /v1/receipt is the free verifiable-randomness utility (256 bits per call; free under per-IP caps: 30 receipts/min burst, 300 receipts/day; $0.02 over cap). Every response is the receipt itself, and the receipt contains the proof information: sources, round, chain position, and how to verify. Trust tiering, stated honestly: the mix and the cited round verify with zero trust in nouli; when the box saw the data is box-attested (not independently verifiable — the prepaid-payment pattern); the one global hash chain makes that attestation tamper-evident, publicly checkable via the chain surface (GET /v1/chain/head, GET /v1/receipt/{id}). The receipt is tamper-evident, not complete — it proves what was recorded, not that everything was; the chain makes gaps visible, not impossible. Paid calls settle USDC directly to nouli's custody address over x402 — nouli relays its own payments in-process (the payer stays gasless; nouli's relay wallet pays the gas), and the receipt cites the settled on-chain transaction. Current deployment: Base mainnet (eip155:8453), native USDC 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 — the 402 names the live network, asset, and amount, so read requirements from the wire, not from docs. (Operators can pin an external facilitator instead — Dexter, https://x402.dexter.cash — by env; the payer-visible wire does not change.)\n\nAgent docs: https://nouli.dev/AGENTS.md · machine-readable sibling: this file · A2A discovery: https://nouli.dev/.well-known/agent-card.json · human page: https://nouli.dev/docs · verify any receipt: https://nouli.dev/verify\n\nPrepaid (opt-in at the operator knob — a box without it answers 404 on every /v1/prepaid path and ignores Bearer keys): for throughput without per-call settlement, send USDC to the box's deposit address (published live at GET /v1/prepaid/deposit — a per-posture env knob no static doc can name honestly), then claim it — POST /v1/prepaid/claim with the deposit tx hash and a signed sign-in message naming that hash — and the response hands you a bearer API key (noul_…, shown exactly once) plus your account's balance. The key (Authorization: Bearer noul_…) serves the paid routes from prepaid funds at the bulk rates — $0.001 (1,000 units) per receipt or flip, $0.002 (2,000 units) per anchor, the route recorded per debit (the prepaid lane IS the bulk lane; the x402 402 names the one-off price), no per-call settlement: one ledger debit per call, cited in the receipt's payment ({ scheme: prepaid, network, payer, deposit_tx, debit_seq } — no transaction key: nothing per-call settles on-chain, honestly). Minimum deposit $25.00 per claim (founding deposit and top-ups alike); the debit is attested by nouli's ledger, not independently verifiable on-chain (the cited founding deposit can be inspected — the verifier prints the honesty note); x402 stays the default posture whose receipts carry the inspectable on-chain trail. The same Bearer header works over /mcp — same rail, same per-key budget (300/min · 8 concurrent per key, exempt from the per-IP paid caps).\n\nThe same rail is also served over the Model Context Protocol at /mcp (POST, streamable HTTP, stateless): tools nouli_receipt (free-first, $0.02 over cap), nouli_anchor ($0.05), nouli_flip ($0.02); x402 payment rides the x402 MCP transport (_meta[\"x402/payment\"]), the caps are shared with these HTTP endpoints — one budget per IP across both surfaces — and prepaid keys draw the same balance under the same per-key budget as HTTP (a prepaid 200 carries _meta[\"nouli/balance-units\"]).",
    "version": "1.0.0"
  },
  "servers": [
    {
      "url": "https://nouli.dev",
      "description": "The deployed box (MVP deployment: Base mainnet)"
    }
  ],
  "paths": {
    "/v1/receipt": {
      "get": {
        "summary": "One verifiable randomness receipt — the free utility facet — free under cap, $0.02 over cap",
        "description": "Dual-mode on one route. Under your IP's free cap (30 receipts/min burst, 300 receipts/day, persisted across restarts) this answers 200 with a free receipt — no `payment` object — served from the pre-mixed Buffer (p50 ≈ 4 ms; a momentarily drained Buffer mixes on demand instead, `X-Nouli-Served-From` says which). Over cap the same route rides the x402 rail: unpaid → 402 with the requirements ($0.02, amount \"20000\") and the error envelope (`free tier exhausted — pay $0.02 to continue or retry later`); paying → verify → settle → 200 with the receipt citing the settlement in `payment`.\n\nThe receipt's `output` is 32 bytes (base64). `issued_at` is mix time. `status: \"degraded\"` + `degraded_reason` appear inside the signed body when the cited drand round was older than 60 s at mix time — degraded receipts still verify and bill full price. With `Authorization: Bearer noul_…` (a funded prepaid key — opt-in lane), the same route serves from prepaid funds instead: no 402, no facilitator, one ledger debit cited as scheme \"prepaid\" in the receipt, and `X-Nouli-Balance-Units` on the 200. An absent, malformed, or unknown key rides the x402 lane exactly as without it — the lanes never mix within one request.",
        "security": [{ "bearerAuth": [] }],
        "responses": {
          "200": {
            "description": "A nouli receipt — the receipt is the response. `payment` is absent on free receipts, present on paid ones.",
            "headers": {
              "X-RateLimit-Remaining-Minute": { "$ref": "#/components/headers/XRateLimitMinute" },
              "X-RateLimit-Remaining-Day": { "$ref": "#/components/headers/XRateLimitDay" },
              "X-Nouli-Served-From": { "$ref": "#/components/headers/XNouliServedFrom" },
              "X-Nouli-Chain-Seq": { "$ref": "#/components/headers/XNouliChainSeq" },
              "X-Nouli-Status": { "$ref": "#/components/headers/XNouliStatus" },
              "Payment-Response": { "$ref": "#/components/headers/PaymentResponse" },
              "X-Nouli-Balance-Units": { "$ref": "#/components/headers/XNouliBalanceUnits" }
            },
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/Receipt" } }
            }
          },
          "402": {
            "description": "Free cap exhausted — pay $0.02 to continue or retry later. The `Payment-Required` header carries the x402 v2 requirements (base64 JSON: x402Version 2, resource, accepts[0] with amount \"20000\", asset, payTo, maxTimeoutSeconds, extra { name: \"USD Coin\", version: \"2\" }). On the prepaid lane the same 402 answers `insufficient_balance` (balance below the call's price) — the envelope detail states the balance and names both reliefs (deposit + claim to top up; pay per call with x402 — retry without the `Authorization: Bearer` header so the request re-enters the x402 lane, whose requirements ride this response's `Payment-Required` header); nothing was debited.",
            "headers": {
              "Payment-Required": { "$ref": "#/components/headers/PaymentRequiredHeader" },
              "X-RateLimit-Remaining-Minute": { "$ref": "#/components/headers/XRateLimitMinute" },
              "X-RateLimit-Remaining-Day": { "$ref": "#/components/headers/XRateLimitDay" }
            },
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } }
            }
          },
          "429": {
            "description": "A cap was bursted: the paid caps (60 receipts/min or 4 concurrent per IP) when paying, or the free caps on a box without a paid rail. Wait `Retry-After` seconds.",
            "headers": {
              "Retry-After": { "$ref": "#/components/headers/RetryAfter" },
              "X-RateLimit-Remaining-Minute": { "$ref": "#/components/headers/XRateLimitMinute" },
              "X-RateLimit-Remaining-Day": { "$ref": "#/components/headers/XRateLimitDay" }
            },
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } }
            }
          },
          "503": {
            "description": "Temporarily unable to serve, nothing charged: with an externally-pinned facilitator, it could not be reached or could not price the route (`facilitator_unavailable`, paid path only; the default relays in-process), settlement failed after one retry (`settlement_failed`, includes a drained relay gas float — fund the relay address), or a cold box has never fetched a drand round and cannot (`drand_unavailable`).",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } }
            }
          },
          "500": {
            "description": "Unexpected internal failure (`internal_error`). If it follows a settled payment, the settlement stands but no receipt was served — the box's operators watch for this; there are no refunds in the MVP.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } }
            }
          }
        }
      }
    },
    "/v1/chain/head": {
      "get": {
        "summary": "The chain head — the one digest that commits to the whole chain",
        "description": "The public chain surface (free, read-only, never capped): `{ \"chain_seq\": <n>, \"head\": \"sha256:…\" }`. `chain_seq` is the last appended position (0 = an empty chain); `head` is the digest the next receipt will carry as `prev_receipt` — and because each link recurses, this one value commits to the entire chain (the value a future Bitcoin Checkpoint would stamp). Never cached (`Cache-Control: no-store`) — the head moves with every append; CORS `*` so verifiers and third-party watchers can fetch it from a browser. Watching this endpoint over time makes retroactive insertion or rewrite of any receipt detectable by anyone, in real time.",
        "responses": {
          "200": {
            "description": "The chain head: the last appended position and the committing digest.",
            "headers": {
              "X-Nouli-Chain-Seq": { "$ref": "#/components/headers/XNouliChainSeq" }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["chain_seq", "head"],
                  "properties": {
                    "chain_seq": { "type": "integer", "description": "The last appended receipt's 1-based chain position (0 = an empty chain)." },
                    "head": { "type": "string", "pattern": "^sha256:[0-9a-f]{64}$", "description": "sha256 of the head receipt's canonical pre-signature form — the value the next receipt carries as prev_receipt; commits to the whole chain." }
                  }
                }
              }
            }
          },
          "503": {
            "description": "`core_unavailable` — the receipt core is unreachable; nothing is cached, retry shortly.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } }
            }
          }
        }
      }
    },
    "/v1/receipt/{id}": {
      "get": {
        "summary": "Receipt lookup by id — the stored record, verbatim, no prior custody needed",
        "description": "The public chain surface (free, read-only, never capped): one stored receipt by its ULID `receipt_id`, served exactly as it was issued — the canonical JSON, byte-identical to what the caller received, with its 1-based chain position in `X-Nouli-Chain-Seq`. CORS `*`: a stranger verifying a receipt can fetch the adjacent records (and the head) without contacting whoever showed them the receipt — the substrate the verifier's chain-link check wants. Unknown ids answer `not_found`.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The receipt's ULID `receipt_id`.",
            "schema": { "type": "string", "pattern": "^[0-9A-Z]{26}$" }
          }
        ],
        "responses": {
          "200": {
            "description": "The stored receipt, verbatim.",
            "headers": {
              "X-Nouli-Chain-Seq": { "$ref": "#/components/headers/XNouliChainSeq" }
            },
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/Receipt" } }
            }
          },
          "404": {
            "description": "`not_found` — no receipt with that id.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } }
            }
          },
          "503": {
            "description": "`core_unavailable` — the receipt core is unreachable; retry shortly.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } }
            }
          }
        }
      }
    },
    "/v1/anchor": {
      "post": {
        "summary": "Proof of Existence — bind your own data to the public beacon ($0.05, paid-only, the headline product)",
        "description": "The Proof of Existence product: \"this existed, at or before this point.\" The request body is up to 1,024 raw bytes (`application/octet-stream`; any content type is accepted — the receipt cites the digest of the exact bytes received). The bytes enter the mix length-prefixed as a third contribution — no recipe change — and are cited digest-only: `sources` carries `{ \"kind\": \"caller\", \"digest\": \"sha256:… of the exact body bytes\" }` between the OS and drand contributions. Larger data: hash it client-side first (sha256) and anchor the 32-byte digest.\n\nThe result: proof your data existed and entered the mix at the cited drand round — a tamper-evident, time-bound record. Agents journal milestones, decisions, and state against it; content and model-output provenance, commit-before-dispute in multi-agent coordination, and compliance logging ride the same call; raffle, sweepstakes, tournament, and audit operators use it as instant, verifiable proof of fairness. Honest limit: the receipt proves what was recorded, not that everything was — the chain makes gaps visible, not impossible; when the box saw the data is attested by the box, made tamper-evident by the public chain (GET /v1/chain/head, GET /v1/receipt/{id}). Every call is 402-first at $0.05 (amount \"50000\"); pay per the x402 flow. Oversized bodies answer 413 before any payment is demanded. With `Authorization: Bearer noul_…` (a funded prepaid key — opt-in lane), the same route serves from prepaid funds instead: no 402, no facilitator, one ledger debit cited as scheme \"prepaid\" in the receipt, and `X-Nouli-Balance-Units` on the 200. An absent, malformed, or unknown key rides the x402 lane exactly as without it — the lanes never mix within one request.",
        "security": [{ "bearerAuth": [] }],
        "requestBody": {
          "description": "The exact bytes to anchor, 0–1,024 of them. Empty is allowed. Nothing about the bytes' meaning is parsed; the receipt cites only their sha256.",
          "required": false,
          "content": {
            "application/octet-stream": {
              "schema": { "type": "string", "format": "binary", "maxLength": 1024 }
            }
          }
        },
        "responses": {
          "200": {
            "description": "An anchor receipt: `endpoint: \"/v1/anchor\"`, standard 32-byte output, `sources` = [os-csprng, caller (your digest), drand], `payment` citing the settled $0.05.",
            "headers": {
              "X-Nouli-Chain-Seq": { "$ref": "#/components/headers/XNouliChainSeq" },
              "X-Nouli-Status": { "$ref": "#/components/headers/XNouliStatus" },
              "Payment-Response": { "$ref": "#/components/headers/PaymentResponse" },
              "X-Nouli-Balance-Units": { "$ref": "#/components/headers/XNouliBalanceUnits" }
            },
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/Receipt" } }
            }
          },
          "400": {
            "description": "The request body did not arrive complete (`invalid_request`) — retry.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } }
            }
          },
          "402": {
            "description": "Unpaid — the anchor is $0.05, paid-only. The `Payment-Required` header carries the requirements (amount \"50000\", resource mimeType application/octet-stream); the envelope body explains the flow. A payment that fails verification also answers 402, `payment_invalid` (wrong amount, stale nonce, reused payment) — fetch a fresh 402 and pay it; nothing is charged. On the prepaid lane the same 402 answers `insufficient_balance` (balance below the call's price) — the envelope detail states the balance and names both reliefs (deposit + claim to top up; pay per call with x402 — retry without the `Authorization: Bearer` header so the request re-enters the x402 lane, whose requirements ride this response's `Payment-Required` header); nothing was debited.",
            "headers": {
              "Payment-Required": { "$ref": "#/components/headers/PaymentRequiredHeader" }
            },
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } }
            }
          },
          "413": {
            "description": "The body exceeded 1,024 bytes (`payload_too_large`) — rejected before any payment is demanded; hash larger data client-side first.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } }
            }
          },
          "429": {
            "description": "Paid caps hit (60 receipts/min or 4 concurrent per IP, shared with the other paid routes). Wait `Retry-After` seconds.",
            "headers": {
              "Retry-After": { "$ref": "#/components/headers/RetryAfter" }
            },
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } }
            }
          },
          "503": {
            "description": "Temporarily unable to serve, nothing charged: settlement failed after one retry (`settlement_failed` — includes a drained relay gas float; fund the relay address and retry), an externally-pinned facilitator unreachable/unpricing (`facilitator_unavailable`; the default relays in-process), or the core went down after settlement (`core_unavailable` — the payment settled but no receipt was served; watched by the operators, no refunds in the MVP).",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } }
            }
          },
          "500": {
            "description": "Unexpected internal failure (`internal_error`).",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } }
            }
          }
        }
      }
    },
    "/v1/flip": {
      "get": {
        "summary": "The impartial tie-breaker that comes with proof — one fair bit, $0.02, paid-only",
        "description": "The tie-breaking facet: a fair coin no stakeholder can accuse you of rigging — for breaking ties between equal candidates, tasks, or claims. For a fully verifiable tie-break, anchor the candidate list first (POST /v1/anchor), then flip the next round — commit, then reveal. The output is `bytes: 1`: the most-significant bit of the first byte of the standard 32-byte output, as `0x00`/`0x01`. No caller input. Sources: [os-csprng, drand]. Every call is 402-first at $0.02 (amount \"20000\"); pay per the x402 flow. With `Authorization: Bearer noul_…` (a funded prepaid key — opt-in lane), the same route serves from prepaid funds instead: no 402, no facilitator, one ledger debit cited as scheme \"prepaid\" in the receipt, and `X-Nouli-Balance-Units` on the 200. An absent, malformed, or unknown key rides the x402 lane exactly as without it — the lanes never mix within one request.",
        "security": [{ "bearerAuth": [] }],
        "responses": {
          "200": {
            "description": "A flip receipt: `endpoint: \"/v1/flip\"`, `output: { encoding: \"hex\", bytes: 1, value: \"0x00\"|\"0x01\" }`, `payment` citing the settled $0.02.",
            "headers": {
              "X-Nouli-Chain-Seq": { "$ref": "#/components/headers/XNouliChainSeq" },
              "X-Nouli-Status": { "$ref": "#/components/headers/XNouliStatus" },
              "Payment-Response": { "$ref": "#/components/headers/PaymentResponse" },
              "X-Nouli-Balance-Units": { "$ref": "#/components/headers/XNouliBalanceUnits" }
            },
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/Receipt" } }
            }
          },
          "402": {
            "description": "Unpaid — the flip is $0.02, paid-only. The `Payment-Required` header carries the requirements (amount \"20000\"); the envelope body explains the flow. (A payment that fails verification also answers 402, `payment_invalid` — fetch a fresh 402, nothing is charged.) On the prepaid lane the same 402 answers `insufficient_balance` (balance below the call's price) — the envelope detail states the balance and names both reliefs (deposit + claim to top up; pay per call with x402 — retry without the `Authorization: Bearer` header so the request re-enters the x402 lane, whose requirements ride this response's `Payment-Required` header); nothing was debited.",
            "headers": {
              "Payment-Required": { "$ref": "#/components/headers/PaymentRequiredHeader" }
            },
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } }
            }
          },
          "429": {
            "description": "Paid caps hit (60 receipts/min or 4 concurrent per IP, shared with the other paid routes). Wait `Retry-After` seconds.",
            "headers": {
              "Retry-After": { "$ref": "#/components/headers/RetryAfter" }
            },
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } }
            }
          },
          "503": {
            "description": "Temporarily unable to serve, nothing charged: settlement failed after one retry (`settlement_failed` — includes a drained relay gas float; fund the relay address), an externally-pinned facilitator unavailable (`facilitator_unavailable`), or the core went down after settlement (`core_unavailable` — the payment settled but no receipt was served; no refunds in the MVP).",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } }
            }
          },
          "500": {
            "description": "Unexpected internal failure (`internal_error`).",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } }
            }
          }
        }
      }
    },
    "/mcp": {
      "post": {
        "summary": "The MCP mirror — the same rail over the Model Context Protocol",
        "description": "POST only, JSON-RPC 2.0, streamable HTTP, stateless, JSON responses (GET/DELETE answer 405). Three tools mirror the HTTP endpoints, prices included: nouli_receipt (free-first: a free receipt under your IP's cap — the same shared counters the HTTP route uses; over cap the $0.02 relief valve), nouli_anchor ($0.05, paid-only; `data`: base64, at most 1,024 bytes raw — hash larger data client-side first), nouli_flip ($0.02, paid-only).\n\nAn unpaid paid-tool call answers a 402-shaped tool result — `isError: true`, the requirements in `structuredContent` (and JSON-encoded in `content[0].text`): `x402Version: 2`, `accepts[0]` with `amount` (\"20000\" = $0.02, \"50000\" = $0.05 — the one-off x402 prices), `asset`, `payTo`, `network`, `maxTimeoutSeconds`. Pay it and retry the same call with the payment payload in `_meta[\"x402/payment\"]` (the x402 MCP transport; client library @x402/mcp). The paid result carries the full receipt in `content[0].text` (settlement embedded in `payment`) and the settlement in `_meta[\"x402/payment-response\"]` — settlement completes before the receipt is mixed, so `payment.transaction` is always real. Refusals are error results, charged nothing: rate_limited, payment_invalid, settlement_failed, facilitator_unavailable.\n\nPrepaid keys work over the mirror with the same rail and the same per-key budget as HTTP: the same `Authorization: Bearer noul_…` header on this request draws from the same balance (one per-key budget across surfaces). A prepaid success carries `_meta[\"nouli/balance-units\"]` (the balance after the debit — HTTP's serve header's analog). A broke key answers an error result whose text is the `insufficient_balance` envelope (both relief paths + the balance) and whose `structuredContent` carries the x402 requirements — an x402 MCP client can pay that call per call and continue (drop the Bearer header on the retry: the lanes never mix within one call).",
        "responses": {
          "200": {
            "description": "The JSON-RPC response: for tools/call, the tool result — a receipt on success, the 402-shaped result when unpaid, an error result (isError: true) on refusals."
          },
          "400": {
            "description": "The JSON-RPC body did not parse (code -32700)."
          },
          "405": {
            "description": "GET/DELETE /mcp: method not allowed — the transport is stateless (code -32000)."
          },
          "413": {
            "description": "The JSON-RPC body exceeded the 1 MB bound (code -32000)."
          }
        }
      }
    },
    "/v1/prepaid/deposit": {
      "get": {
        "summary": "The deposit address — where a claimable deposit goes (opt-in lane)",
        "description": "The discovery surfaces document the prepaid flow, but the deposit address itself is a per-posture env knob — no static doc can name it honestly. The box serves it: GET /v1/prepaid/deposit names the dedicated address, the network and the only claimable asset, and the minimum deposit. Send a plain USDC transfer() to that address on `network` (the address is deposit-dedicated — any transfer to it is a claimable deposit, never an x402 settlement), wait for it to confirm, then claim at `claim_endpoint` (the exact message shape in AGENTS.md). Minimum deposit $25.00 (25000000 6dp units) per claim — founding deposit and top-ups alike (one number; a buyer low on balance tops up $25, not $5 — prepaid is the bulk lane, and small users stay on x402).\n\nA box with the lane closed (the operator never opted in) has no prepaid surfaces: this path falls through to the 404 envelope, and Bearer keys are ignored.",
        "responses": {
          "200": {
            "description": "The live deposit facts. `asset` is absent on an unknown deposit network (the claim layer refuses it anyway).",
            "headers": {
              "Cache-Control": { "description": "no-store — the address is per-posture; a cache must not pin one box's address to another", "schema": { "type": "string" } }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": false,
                  "required": ["deposit_address", "network", "min_deposit_units", "claim_endpoint", "how"],
                  "properties": {
                    "deposit_address": { "type": "string", "pattern": "^0x[0-9a-fA-F]{40}$", "description": "Send a plain USDC transfer() here — the address is deposit-dedicated: any transfer to it is a claimable deposit, never an x402 settlement." },
                    "network": { "type": "string", "description": "CAIP-2 — where the deposit must land (the box's NOULI_NETWORK).", "examples": ["eip155:8453"] },
                    "asset": { "type": "string", "pattern": "^0x[0-9a-fA-F]{40}$", "description": "The USDC contract on `network` — the only claimable asset." },
                    "min_deposit_units": { "type": "integer", "minimum": 1, "description": "The minimum claimable deposit in 6dp USDC units ($25.00 = 25000000), per claim — founding deposit and top-ups alike." },
                    "claim_endpoint": { "type": "string", "const": "/v1/prepaid/claim" },
                    "how": { "type": "string", "format": "uri", "description": "https://nouli.dev/AGENTS.md — the full walk-through." }
                  }
                }
              }
            }
          },
          "404": {
            "description": "The lane is closed on this box (no operator opt-in) — prepaid refuses to exist; the envelope's detail lists what the box serves.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } }
            }
          }
        }
      }
    },
    "/v1/prepaid/balance": {
      "get": {
        "summary": "Your prepaid account's standing — bearer-keyed (opt-in lane)",
        "description": "Reports the calling key's account: `deposit_tx` (the founding deposit — the account identifier), `balance_units` (SUM of the ledger entries — derived, never stored), and `debits` (the next debit_seq). Strictly by credential: an absent, malformed, unknown, or revoked key is the same 401 unknown_key refusal (no enumeration). Installed only when the prepaid lane is; otherwise 404.\n\nYour over-debit check is the debit sequence across your own receipts; this surface is the quicker read.",
        "security": [{ "bearerAuth": [] }],
        "responses": {
          "200": {
            "description": "The account's standing. The same units ride `X-Nouli-Balance-Units` on every prepaid 200.",
            "headers": {
              "X-Nouli-Balance-Units": { "$ref": "#/components/headers/XNouliBalanceUnits" },
              "Cache-Control": { "description": "private — account state, keyed by credential", "schema": { "type": "string" } }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": false,
                  "required": ["deposit_tx", "balance_units", "debits"],
                  "properties": {
                    "deposit_tx": { "type": "string", "pattern": "^0x[0-9a-fA-F]{64}$", "description": "The founding deposit tx hash — the account identifier." },
                    "balance_units": { "type": "integer", "minimum": 0, "description": "6dp USDC units; the bulk debits are 1,000 units per receipt/flip and 2,000 per anchor" },
                    "debits": { "type": "integer", "minimum": 1, "description": "The next debit_seq (the recorded debit count plus one)." }
                  }
                }
              }
            }
          },
          "401": {
            "description": "`unknown_key` — the key is absent, malformed, unknown, or revoked. Send `Authorization: Bearer noul_…`.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } }
            }
          },
          "404": {
            "description": "The lane is closed on this box — prepaid refuses to exist.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } }
            }
          }
        }
      }
    },
    "/v1/prepaid/claim": {
      "post": {
        "summary": "Claim a deposit: on-chain USDC → funded prepaid account + API key (opt-in lane)",
        "description": "The credit side — one endpoint, one flow, no watcher: send USDC to the deposit address (GET /v1/prepaid/deposit names it), then claim: the deposit tx hash plus a signed message proving the sending wallet is yours. The box verifies the transfer on a public RPC, credits the wallet's 1:1 account, and issues the key.\n\nThe `message` is a CAIP-122/EIP-4361 sign-in string: the domain is the configured public origin (never a Host header — the SIWX lesson), the statement binds the deposit tx hash, `Chain ID` is the box's network (rehearsal 84532 vs production 8453 never cross), `Version: 1`, a caller-chosen `Nonce`, and `Issued At` within 5 minutes. `signature` is EIP-191 `personal_sign` over the UTF-8 message; ecrecover must equal the tx sender. Only a plain USDC `transfer()` observed on-chain, confirmed, ≥ the minimum, is claimable (never an EIP-3009 settlement — the address is deposit-dedicated). No server-issued nonce: replay is dead because the same tx hash cannot double-credit, and a duplicate claim is refused 409 — the key is never re-issued. The 200 shows the raw key exactly once (no-store); a top-up from the same wallet credits the same account and returns a fresh key (old keys keep working). Lost key: the operator can revoke it (runbook) after a fresh deposit.",
        "requestBody": {
          "description": "{ deposit_tx, message, signature } — the deposit's tx hash, the CAIP-122/EIP-4361 sign-in string per AGENTS.md's exact shape, and the EIP-191 personal_sign signature over its UTF-8 bytes.",
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "additionalProperties": false,
                "required": ["deposit_tx", "message", "signature"],
                "properties": {
                  "deposit_tx": { "type": "string", "pattern": "^0x[0-9a-fA-F]{64}$", "description": "The deposit transaction's hash (0x + 64 hex)." },
                  "message": { "type": "string", "description": "The CAIP-122/EIP-4361 sign-in string, per AGENTS.md's exact shape (domain = the configured public origin; statement binds the tx hash; Chain ID = the box's network; Version 1; Nonce caller-chosen; Issued At within 5 minutes)." },
                  "signature": { "type": "string", "pattern": "^0x[0-9a-fA-F]{130}$", "description": "EIP-191 personal_sign over the UTF-8 message (65-byte r||s||v)." }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The deposit verified on-chain and was credited: the account is funded and the bearer key issued. The raw key is shown exactly once (no-store — it must not sit in any cache).",
            "headers": {
              "Cache-Control": { "description": "no-store — the raw key must not sit in any cache", "schema": { "type": "string" } }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": false,
                  "required": ["key", "balance_units", "account"],
                  "properties": {
                    "key": { "type": "string", "pattern": "^noul_[A-Za-z0-9_-]{43}$", "description": "The bearer credential — shown once, never again; lose it and the operator can only revoke it (runbook) after a fresh deposit." },
                    "balance_units": { "type": "integer", "minimum": 1, "description": "The account's balance after the credit." },
                    "account": { "type": "string", "pattern": "^0x[0-9a-fA-F]{64}$", "description": "The founding deposit tx hash — the account id. A top-up from the same wallet credits this same account and returns a fresh key (old keys keep working)." }
                  }
                }
              }
            }
          },
          "400": {
            "description": "The body did not arrive complete or is not the JSON object { deposit_tx, message, signature } (`invalid_request`); the message is not one this box honors (`claim_invalid_message` — wrong domain (it must name this origin, never a host you were told to use), wrong chain id, missing fields, or the statement does not bind the deposit tx hash — rebuild per AGENTS.md and sign again), is outside the 5-minute window (`claim_expired`), or the deposit could not be verified (`claim_tx_unverified` — unconfirmed, reverted, not a plain USDC transfer to the deposit address, or below the $25.00 per-claim minimum; wait for confirmation and claim again — the deposit stays yours on-chain either way).",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } }
            }
          },
          "403": {
            "description": "`claim_payer_mismatch` — the signature does not prove the claiming wallet sent this deposit (the signer is not the transaction's sender, or the message names a different wallet). Claims are 1:1: a deposit funds exactly the wallet that sent it.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } }
            }
          },
          "409": {
            "description": "`claim_duplicate` — this deposit tx was already claimed; the key was issued once at the original claim and is never re-issued. A replay can never yield a key to anyone.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } }
            }
          },
          "413": {
            "description": "The claim body exceeded 8 KB (`payload_too_large`) — deposit_tx, message, and signature only.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } }
            }
          },
          "503": {
            "description": "`claim_rpc_unavailable` — the box cannot read the chain right now to verify the deposit; nothing was credited; retry shortly.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } }
            }
          }
        }
      }
    },
    "/.well-known/nouli-keys.json": {
      "get": {
        "summary": "The append-only Ed25519 signing keys verifiers check against",
        "description": "Receipts carry `key_id`, never the key; verifiers resolve the public key here. Append-only: new keys appear with `status: \"active\"`, old ones move to `retired` (verifiers warn) then `revoked` (verifiers fail). Current key: `2026q4`.",
        "responses": {
          "200": {
            "description": "The keys file, one entry per key.",
            "headers": {
              "Cache-Control": { "description": "public, max-age=60 — append-only public data, cache briefly", "schema": { "type": "string" } }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": { "$ref": "#/components/schemas/KeysFileEntry" }
                }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "description": "A prepaid API key issued at claim time: `Authorization: Bearer noul_<43 base64url chars>` (32 random bytes, 256-bit; shown once, SHA-256-hashed at rest, revocable via the operator runbook). A funded key serves the paid routes from prepaid funds — no per-call settlement. Optional on the paid routes: an absent, malformed, or unknown key takes the x402 lane exactly as without it — the lanes never mix within one request. Works over /mcp too (same header, same per-key budget)."
      }
    },
    "headers": {
      "XRateLimitMinute": {
        "description": "Your remaining receipts in the current 60 s window (per IP). Free route only.",
        "schema": { "type": "integer", "minimum": 0 }
      },
      "XRateLimitDay": {
        "description": "Your remaining receipts today (UTC day, per IP, persisted across restarts). Free route only.",
        "schema": { "type": "integer", "minimum": 0 }
      },
      "RetryAfter": {
        "description": "Seconds to wait before retrying (the burst window's edge, or the next UTC day for the free daily cap).",
        "schema": { "type": "integer", "minimum": 1 }
      },
      "XNouliServedFrom": {
        "description": "Which mixing served a free receipt: `buffer` (pre-mixed against a fresh round before the request arrived) or `ondemand` (the Buffer was momentarily drained).",
        "schema": { "type": "string", "enum": ["buffer", "ondemand"] }
      },
      "XNouliChainSeq": {
        "description": "The receipt's 1-based position on the global chain (this box's chain).",
        "schema": { "type": "integer", "minimum": 1 }
      },
      "XNouliStatus": {
        "description": "Present only on degraded receipts (`degraded`): the cited drand round was older than 60 s at mix time. Absent on normal receipts.",
        "schema": { "type": "string", "enum": ["degraded"] }
      },
      "XNouliBalanceUnits": {
        "description": "On a prepaid-served 200 only: your account's remaining balance in 6dp USDC units after this call's debit. Absent on x402-served receipts (nothing account-shaped rides the per-call lane).",
        "schema": { "type": "integer", "minimum": 0 }
      },
      "PaymentRequiredHeader": {
        "description": "x402 v2 requirements, base64-encoded JSON (`PaymentRequired`). This is what you pay — the authoritative amount, asset, pay-to address, and network for this route.",
        "required": true,
        "schema": { "type": "string", "contentEncoding": "base64", "contentMediaType": "application/json" }
      },
      "PaymentResponse": {
        "description": "On paid success: the settlement, base64-encoded JSON (x402 SettlementResponse, buyer-facing) — `success`, `transaction`, `network`, `payer`. The receipt's `payment` cites the same transaction.",
        "schema": { "type": "string", "contentEncoding": "base64", "contentMediaType": "application/json" }
      }
    },
    "schemas": {
      "ErrorEnvelope": {
        "type": "object",
        "description": "Every non-200 body. A walletless agent can self-explain: `docs_url` is the human page, `error` the machine-readable code, `detail` the plain-language sentence. Codes: payment_required, payment_invalid, rate_limited, insufficient_balance (prepaid), unknown_key (prepaid balance surface), claim_invalid_message, claim_expired, claim_payer_mismatch, claim_tx_unverified, claim_rpc_unavailable, claim_duplicate (the prepaid claim flow), facilitator_unavailable, settlement_failed, drand_unavailable, core_unavailable, payload_too_large, invalid_request, not_found, internal_error.",
        "additionalProperties": false,
        "required": ["error", "docs_url", "detail"],
        "properties": {
          "error": {
            "type": "string",
            "enum": [
              "payment_required",
              "payment_invalid",
              "rate_limited",
              "insufficient_balance",
              "unknown_key",
              "claim_invalid_message",
              "claim_expired",
              "claim_payer_mismatch",
              "claim_tx_unverified",
              "claim_rpc_unavailable",
              "claim_duplicate",
              "facilitator_unavailable",
              "settlement_failed",
              "drand_unavailable",
              "core_unavailable",
              "payload_too_large",
              "invalid_request",
              "not_found",
              "internal_error"
            ]
          },
          "docs_url": {
            "type": "string",
            "const": "https://nouli.dev/docs",
            "description": "The human page — explains every code in plain language."
          },
          "detail": { "type": "string" }
        }
      },
      "PaymentRequired": {
        "type": "object",
        "description": "The x402 v2 requirements — carried base64 in a 402's `Payment-Required` header. Pay `accepts[0]` exactly; treat it as the truth for the live network, asset, amount, and pay-to address.",
        "required": ["x402Version", "resource", "accepts"],
        "properties": {
          "x402Version": { "type": "integer", "const": 2 },
          "resource": { "$ref": "#/components/schemas/ResourceInfo" },
          "accepts": {
            "type": "array",
            "items": { "$ref": "#/components/schemas/PaymentRequirements" },
            "minItems": 1
          }
        }
      },
      "ResourceInfo": {
        "type": "object",
        "required": ["url"],
        "properties": {
          "url": { "type": "string", "format": "uri" },
          "description": { "type": "string" },
          "mimeType": { "type": "string" }
        }
      },
      "PaymentRequirements": {
        "type": "object",
        "description": "One payable requirement. `amount` is atomic USDC units: \"20000\" = $0.02 (receipt over cap, flip — the one-off prices), \"50000\" = $0.05 (anchor). Paying means EIP-3009 `transferWithAuthorization` (EIP-712 domain from `extra` + `asset`), settled by the facilitator directly to `payTo` — the payer stays gasless.",
        "required": ["scheme", "network", "asset", "amount", "payTo", "maxTimeoutSeconds", "extra"],
        "properties": {
          "scheme": { "type": "string", "const": "exact" },
          "network": { "type": "string", "description": "CAIP-2 chain, e.g. eip155:8453 (Base mainnet) in the current deployment.", "examples": ["eip155:8453"] },
          "asset": { "type": "string", "description": "USDC contract address on `network`.", "pattern": "^0x[0-9a-fA-F]{40}$" },
          "amount": { "type": "string", "description": "Atomic units of `asset`, as a string.", "pattern": "^[0-9]+$" },
          "payTo": { "type": "string", "description": "nouli's custody address — settlement goes directly to it; no intermediary holds funds.", "pattern": "^0x[0-9a-fA-F]{40}$" },
          "maxTimeoutSeconds": { "type": "integer", "const": 300 },
          "extra": {
            "type": "object",
            "description": "The EIP-712 domain for signing, named in `extra` and read from the wire: on the current deployment's native mainnet USDC it is name \"USD Coin\", version \"2\" — always sign under the domain the 402 carries.",
            "properties": {
              "name": { "type": "string", "description": "The asset's EIP-712 domain name — \"USD Coin\" on the current deployment's native mainnet USDC; always read it from the 402." },
              "version": { "type": "string", "const": "2" }
            }
          }
        }
      },
      "Receipt": {
        "type": "object",
        "description": "The signed, hash-chained artifact — the response body itself. Signed as JCS (RFC 8785) over the receipt minus `signature`. Free receipts carry no `payment`; anchors add the `caller` source; flips use the 1-byte hex output. Verify any receipt at https://nouli.dev/verify or with `npx nouli-verify`.",
        "additionalProperties": false,
        "required": ["spec", "receipt_id", "issued_at", "endpoint", "sources", "output", "prev_receipt", "signature"],
        "properties": {
          "spec": { "type": "string", "const": "nouli.v1" },
          "receipt_id": { "type": "string", "description": "ULID — time-sortable unique id." },
          "issued_at": { "type": "string", "format": "date-time", "description": "RFC 3339 UTC — mix time, not serve time." },
          "endpoint": { "type": "string", "enum": ["/v1/receipt", "/v1/anchor", "/v1/flip"] },
          "sources": {
            "type": "array",
            "description": "The contributions in mix order: /v1/receipt and /v1/flip cite [os-csprng, drand]; /v1/anchor cites [os-csprng, caller, drand]. Secret contributions are cited digest-only.",
            "minItems": 2,
            "maxItems": 3,
            "items": {
              "oneOf": [
                { "$ref": "#/components/schemas/OsCsprngSource" },
                { "$ref": "#/components/schemas/CallerSource" },
                { "$ref": "#/components/schemas/DrandSource" }
              ]
            }
          },
          "output": {
            "oneOf": [
              { "$ref": "#/components/schemas/RandomnessOutput" },
              { "$ref": "#/components/schemas/FlipOutput" }
            ]
          },
          "prev_receipt": {
            "type": "string",
            "pattern": "^sha256:[0-9a-f]{64}$",
            "description": "The chain link: sha256 of the previous receipt's canonical pre-signature form. Genesis: sha256 of key_id ‖ \"genesis\"."
          },
          "status": {
            "type": "string",
            "const": "degraded",
            "description": "Present only when the cited drand round was older than 60 s at mix time (a beacon stall). Degraded receipts still verify and bill full price — freshness is quality, not correctness."
          },
          "degraded_reason": {
            "type": "string",
            "description": "Why the receipt is degraded, e.g. the cited round's age at mix time. Present only on degraded receipts."
          },
          "payment": {
            "oneOf": [
              { "$ref": "#/components/schemas/Payment" },
              { "$ref": "#/components/schemas/PrepaidPayment" },
              { "type": "null" }
            ],
            "description": "The payment record — present on paid receipts, absent on free ones. x402 per-call receipts cite the settled on-chain transaction (scheme \"exact\", inspectable on the network's explorer); prepaid receipts cite the ledger debit (scheme \"prepaid\", box-attested — see PrepaidPayment)."
          },
          "signature": { "$ref": "#/components/schemas/Signature" }
        }
      },
      "RandomnessOutput": {
        "type": "object",
        "additionalProperties": false,
        "required": ["encoding", "bytes", "value"],
        "properties": {
          "encoding": { "type": "string", "const": "base64" },
          "bytes": { "type": "integer", "const": 32 },
          "value": { "type": "string", "contentEncoding": "base64", "description": "The 32-byte mix output (the recipe's seed)." }
        }
      },
      "FlipOutput": {
        "type": "object",
        "additionalProperties": false,
        "required": ["encoding", "bytes", "value"],
        "properties": {
          "encoding": { "type": "string", "const": "hex" },
          "bytes": { "type": "integer", "const": 1 },
          "value": { "type": "string", "enum": ["0x00", "0x01"], "description": "The most-significant bit of the standard output's first byte." }
        }
      },
      "OsCsprngSource": {
        "type": "object",
        "additionalProperties": false,
        "required": ["kind", "algorithm", "digest"],
        "properties": {
          "kind": { "type": "string", "const": "os-csprng" },
          "algorithm": { "type": "string", "const": "getrandom" },
          "digest": { "type": "string", "pattern": "^sha256:[0-9a-f]{64}$", "description": "Digest-only citation — the secret bytes themselves never ship." }
        }
      },
      "CallerSource": {
        "type": "object",
        "additionalProperties": false,
        "required": ["kind", "digest"],
        "properties": {
          "kind": { "type": "string", "const": "caller" },
          "digest": { "type": "string", "pattern": "^sha256:[0-9a-f]{64}$", "description": "Digest of the caller's exact body bytes (anchor only) — the receipt proves your data entered the mix without publishing it." }
        }
      },
      "DrandSource": {
        "type": "object",
        "additionalProperties": false,
        "required": ["kind", "beacon_id", "scheme", "chain_hash", "round", "signature", "verify"],
        "properties": {
          "kind": { "type": "string", "const": "drand" },
          "beacon_id": { "type": "string", "const": "quicknet" },
          "scheme": { "type": "string", "const": "bls-unchained-g1-rfc9380" },
          "chain_hash": { "type": "string", "const": "52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971" },
          "round": { "type": "integer", "minimum": 0, "description": "The cited beacon round; fresh at mix time = within the 60 s freshness bound." },
          "signature": { "type": "string", "description": "The round's BLS signature (the public contribution to the mix)." },
          "verify": { "type": "string", "format": "uri", "description": "Where anyone can fetch the cited round: https://api.drand.sh/v2/beacons/quicknet/rounds/<n>" }
        }
      },
      "Payment": {
        "type": "object",
        "description": "Mirrors the x402 SettlementResponse facts: where and by whom the $0.02/$0.05 settled. The transaction hash is inside the signed body — the receipt cannot exist before settlement.",
        "additionalProperties": false,
        "required": ["scheme", "network", "transaction", "payer"],
        "properties": {
          "scheme": { "type": "string", "const": "exact" },
          "network": { "type": "string", "description": "CAIP-2 — where THIS receipt settled; receipts are truthful about the network they settled on, across deployments.", "examples": ["eip155:8453"] },
          "transaction": { "type": "string", "pattern": "^0x[0-9a-fA-F]{64}$", "description": "The on-chain USDC transfer — inspect it on the network's explorer." },
          "payer": { "type": "string", "pattern": "^0x[0-9a-fA-F]{40}$" }
        }
      },
      "PrepaidPayment": {
        "type": "object",
        "description": "A prepaid debit citation (opt-in lane): the spend is a ledger entry, attested by nouli's signature — honestly NOT independently verifiable on-chain. There is no per-call transaction key (nothing per-call settled); the cited founding deposit CAN be inspected on the explorer. The verifier prints an info note, never a claim of independent confirmation (ADR 0001). Your over-debit check: the debit_seq sequence across your own receipts — gaps or reuse are evidence.",
        "additionalProperties": false,
        "required": ["scheme", "network", "payer", "deposit_tx", "debit_seq"],
        "properties": {
          "scheme": { "type": "string", "const": "prepaid" },
          "network": { "type": "string", "description": "CAIP-2 — the network the deposit was observed on (the box's NOULI_NETWORK).", "examples": ["eip155:8453"] },
          "payer": { "type": "string", "pattern": "^0x[0-9a-fA-F]{40}$", "description": "The depositing wallet (the claim signer)." },
          "deposit_tx": { "type": "string", "pattern": "^0x[0-9a-fA-F]{64}$", "description": "The account's founding deposit tx hash, lowercase — the account identifier. Inspectable on the explorer; the per-call debit itself is a ledger entry, not an on-chain transaction." },
          "debit_seq": { "type": "integer", "minimum": 1, "description": "Per-account monotonic, 1-based — gaps or reuse in the sequence across your receipts are evidence of over-debiting." }
        }
      },
      "Signature": {
        "type": "object",
        "additionalProperties": false,
        "required": ["algorithm", "key_id", "value"],
        "properties": {
          "algorithm": { "type": "string", "const": "Ed25519" },
          "key_id": { "type": "string", "examples": ["2026q4"], "description": "Resolve the public key at https://nouli.dev/.well-known/nouli-keys.json (append-only; retired warns, revoked fails)." },
          "value": { "type": "string", "contentEncoding": "base64", "description": "64-byte Ed25519 signature over the canonical (JCS) pre-signature form." }
        }
      },
      "KeysFileEntry": {
        "type": "object",
        "additionalProperties": false,
        "required": ["key_id", "algorithm", "public_key", "created", "status"],
        "properties": {
          "key_id": { "type": "string" },
          "algorithm": { "type": "string", "const": "Ed25519" },
          "public_key": { "type": "string", "contentEncoding": "base64", "description": "32-byte Ed25519 public key." },
          "created": { "type": "string", "format": "date-time" },
          "status": { "type": "string", "enum": ["active", "retired", "revoked"] }
        }
      }
    }
  }
}
