{
  "openapi": "3.1.0",
  "info": {
    "contact": {
      "name": "Satsignal",
      "url": "https://satsignal.cloud"
    },
    "description": "Hand-authored OpenAPI 3.1 spec for the Satsignal notary service.\n\nSee the integration guides at https://satsignal.cloud/docs.html for getting-started flows. This spec is the machine-readable reference - import into Postman or render with any OpenAPI viewer.\n\nVocabulary: the canonical public vocabulary is `folder` (grouping noun) and `proof` (artifact noun) on every surface. Legacy spellings are INBOUND-ONLY compatibility aliases - requests carrying them keep working indefinitely:\n  - routes: `/api/v1/matters` -> `/api/v1/folders`, `/api/v1/receipts/{id}` -> `/api/v1/proofs/{id}`\n  - request keys / query params: `matter_slug` -> `folder_slug`\n  - scopes: `receipts:read` -> `proofs:read`, `receipts:annotate` -> `proofs:annotate`\nEvery 2xx JSON response emits canonical keys ONLY (`proof_id`, `proof_url`, `folder_slug`, `folder_id`, `folder_name`, `folder_anchor_count`, `folder_anchors_url`, `folder`/`folders` containers); the legacy response keys are no longer emitted.",
    "license": {
      "name": "Documentation under CC BY 4.0",
      "url": "https://creativecommons.org/licenses/by/4.0/"
    },
    "title": "Satsignal API",
    "version": "1.0.0"
  },
  "servers": [
    {
      "description": "Production",
      "url": "https://app.satsignal.cloud"
    }
  ],
  "tags": [
    {"description": "Anchor a sha (standard), a sealed commitment (sealed/blind), or a manifest of leaves on-chain.", "name": "Anchors"},
    {"description": "Workspace folders (canonical name). Legacy spelling: matters.", "name": "Folders"},
    {"description": "Workspace quota + plan window.", "name": "Usage"},
    {"description": "Configure SaaS sources (Stripe, GitHub, Langfuse, none) to anchor inbound webhook bodies; receive signed deliveries.", "name": "Webhooks"},
    {"description": "Programmatic scoped key minting - the keys:admin lane (decision 0047). Mint per-tenant scoped sub-keys, list your mint lineage, revoke. Enabled per-host by SATSIGNAL_NOTARY_KEY_MINT_API_ENABLED.", "name": "Keys"},
    {"description": "Structured provenance ingest (satsignal.provenance.v1 manifest).", "name": "Provenance"},
    {"description": "Deterministic workspace audit packet (zip).", "name": "Audit"},
    {"description": "Bearer-auth .mbnt bundle download.", "name": "Bundle"},
    {"description": "Public hash-existence oracle, no auth, CORS-open, rate-limited per IP.", "name": "Public"},
    {"description": "Deprecated legacy spellings of canonical endpoints. Requests are still accepted indefinitely (identical handlers); responses emit canonical keys only.", "name": "Legacy"}
  ],
  "paths": {
    "/api/v1/match-local": {
      "post": {
        "description": "Find a proof you already created in YOUR workspace from a digest you compute locally from the original file — the file bytes never leave your client. Send candidate roots (the raw SHA-256 of the file for standard proofs and/or a manifest merkle root); the server HMACs each with your workspace's secret and returns the first matching proof. Scoped strictly to the authenticated key's workspace: no cross-workspace lookup and no global existence oracle — a miss is a uniform envelope. Sealed proofs are never matchable (they expose no salt-independent digest). Standard + manifest only.",
        "operationId": "matchLocal",
        "requestBody": {
          "content": {
            "application/json": {
              "examples": {
                "standard": {
                  "summary": "Raw file SHA-256 (standard proof)",
                  "value": {
                    "candidates": [
                      "deadbeef00000000000000000000000000000000000000000000000000000000"
                    ]
                  }
                },
                "multi": {
                  "summary": "Raw SHA-256 plus a manifest merkle root",
                  "value": {
                    "candidates": [
                      "1111111111111111111111111111111111111111111111111111111111111111",
                      "2222222222222222222222222222222222222222222222222222222222222222"
                    ]
                  }
                }
              },
              "schema": {
                "$ref": "#/components/schemas/MatchLocalRequest"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/MatchLocalResult"
                }
              }
            },
            "description": "A matching proof in your workspace, or a uniform miss envelope (HTTP 200 either way)."
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Per-workspace match-local rate limit exceeded (default 5000/h). Body adds a `retry_after` (seconds); X-RateLimit-* headers describe the window."
          }
        },
        "security": [
          {
            "BearerAuth": [
              "proofs:read"
            ]
          }
        ],
        "summary": "Find your workspace's proof from a local digest",
        "tags": [
          "Anchors"
        ]
      }
    },
    "/api/v1/anchors": {
      "get": {
        "description": "Workspace-wide anchor listing across every folder the API key can see. Cross-folder audit counterpart to /api/v1/folders/{slug}/anchors. Soft-deleted proofs are included because chain anchors are permanent.",
        "operationId": "listAnchors",
        "parameters": [
          {"$ref": "#/components/parameters/Limit"},
          {"$ref": "#/components/parameters/Before"},
          {"$ref": "#/components/parameters/SessionIdFilter"},
          {"$ref": "#/components/parameters/MatterSlugFilter"},
          {"$ref": "#/components/parameters/FolderSlugFilter"}
        ],
        "responses": {
          "200": {
            "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AnchorListResponse"}}},
            "description": "Paginated anchor list.",
            "headers": {
              "X-RateLimit-Limit": {"$ref": "#/components/headers/XRateLimitLimit"},
              "X-RateLimit-Remaining": {"$ref": "#/components/headers/XRateLimitRemaining"},
              "X-RateLimit-Reset": {"$ref": "#/components/headers/XRateLimitReset"},
              "X-RateLimit-Window": {"$ref": "#/components/headers/XRateLimitWindow"}
            }
          },
          "400": {"$ref": "#/components/responses/BadRequest"},
          "401": {"$ref": "#/components/responses/Unauthorized"},
          "403": {"$ref": "#/components/responses/Forbidden"},
          "404": {"$ref": "#/components/responses/NotFound"}
        },
        "security": [{"BearerAuth": ["proofs:read"]}],
        "summary": "List anchors across the workspace",
        "tags": ["Anchors"]
      },
      "post": {
        "description": "Create an on-chain anchor. Three mutually-exclusive request shapes are dispatched via the request body:\n\n  - **standard** (default): commit sha256_hex + file_size for a single file\n  - **sealed**: commit a byte_exact_commitment (HMAC) for a salt-bearing or blind file; salt_b64 optional (omit for blind)\n  - **manifest**: commit a Merkle root over an items[] array (presence of items[] selects this mode regardless of the `mode` field)\n\nManifest detection: the body's `items[]` key selects manifest mode; sending `mode:'manifest'` without items[] or items[] together with sha256_hex/file_size produces a 400 `mode_conflict`. Note: `manifest` here is a request shape (a multi-item evidence batch), orthogonal to the Standard/Sealed privacy axis. A sealed manifest is created via `mode:'sealed'` + `proof_set.chunk_merkle`, not via `items[]`; the `mode` field is a wire discriminator, not a privacy taxonomy.\n\nIdempotency-Key (optional request header): same key + identical body returns the verbatim cached response with `X-Idempotent-Replayed: true` (no broadcast, no quota tick). Same key + different body returns 409 `idempotency_key_reuse_body_mismatch`. 24h TTL, per-(workspace, key) scope. See components/headers/IdempotencyKey.\n\nRate-limit headers are surfaced on every 2xx and on the 429 `quota_exceeded` body.",
        "operationId": "createAnchor",
        "parameters": [
          {"$ref": "#/components/parameters/IdempotencyKey"}
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "examples": {
                "manifest": {"summary": "Manifest of leaves", "value": {"folder_slug": "acme-events", "items": [{"label": "leaf-1", "sha256_hex": "11111111111111111111111111111111111111111111111111111111111111aa"}, {"label": "leaf-2", "sha256_hex": "11111111111111111111111111111111111111111111111111111111111111bb"}], "label": "AcmeCorp daily batch"}},
                "sealed": {"summary": "Sealed (salt-bearing)", "value": {"byte_exact_commitment": "deadbeef00000000000000000000000000000000000000000000000000000000", "file_size": 4096, "folder_slug": "acme-events", "label": "AcmeCorp sealed report", "mode": "sealed", "retain_days": 7, "salt_b64": "c2FsdHk="}},
                "standard": {"summary": "Standard file anchor", "value": {"file_size": 12345, "folder_slug": "acme-events", "label": "AcmeCorp run-42", "sha256_hex": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"}},
                "redactable_csv_row_v1": {"summary": "Redactable CSV anchor (csv-row-v1) - supports later selective disclosure.", "value": {"sha256_hex": "b6169804f701eaea3cf83309700b982dd243bc331fce169f3de4b666c558dd0f", "file_size": 4096, "folder_slug": "acme-events", "label": "AcmeCorp redactable export", "mode": "standard", "proof_set": {"byte_exact": {"algo": "sha256", "hash": "b6169804f701eaea3cf83309700b982dd243bc331fce169f3de4b666c558dd0f", "size": 4096}, "chunk_merkle": {"algo": "sha256", "scheme": "csv-row-v1", "leaf_count": 2, "root": "d86ba496071d0775ac100322a9fb9bdaa06664f9f54a0cdba8b3ae55b0f65fbd"}}, "proof_leaves": {"scheme": "csv-row-v1", "merkle_leaves": ["9b2c87bd65a444c3615c30841ed23791291b9caf913d6216b7bdcfe84d3c50b2", "b49d21ac7eb4617800af9ad07d5bdd255abd6c9d18871287c0fd71d1034e7c97"], "metadata": {"leaf_count": 2}}}}
              },
              "schema": {
                "discriminator": {"propertyName": "mode"},
                "oneOf": [
                  {"$ref": "#/components/schemas/AnchorRequestStandard"},
                  {"$ref": "#/components/schemas/AnchorRequestSealed"},
                  {"$ref": "#/components/schemas/AnchorRequestManifest"}
                ]
              }
            }
          },
          "description": "Anchor request body. `mode` defaults to `standard`; manifest mode is auto-selected when `items[]` is present.",
          "required": true
        },
        "responses": {
          "200": {
            "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AnchorResponse"}}},
            "description": "Anchor created. May also be a same-body Idempotency-Key replay (look for `X-Idempotent-Replayed: true`) or a dedup-hit (`duplicate: true` in body, with the prior proof_id).",
            "headers": {
              "X-Idempotent-Replayed": {"$ref": "#/components/headers/XIdempotentReplayed"},
              "X-RateLimit-Limit": {"$ref": "#/components/headers/XRateLimitLimit"},
              "X-RateLimit-Remaining": {"$ref": "#/components/headers/XRateLimitRemaining"},
              "X-RateLimit-Reset": {"$ref": "#/components/headers/XRateLimitReset"},
              "X-RateLimit-Window": {"$ref": "#/components/headers/XRateLimitWindow"}
            }
          },
          "400": {"$ref": "#/components/responses/BadRequest"},
          "401": {"$ref": "#/components/responses/Unauthorized"},
          "403": {"$ref": "#/components/responses/Forbidden"},
          "404": {"$ref": "#/components/responses/FolderNotFound"},
          "409": {"$ref": "#/components/responses/Conflict"},
          "429": {"$ref": "#/components/responses/QuotaExceeded"}
        },
        "security": [{"BearerAuth": ["anchors:create"]}],
        "summary": "Create an on-chain anchor (standard / sealed / manifest)",
        "tags": ["Anchors"]
      }
    },
    "/api/v1/anchors/{bundle_id}": {
      "get": {
        "description": "Fetch one proof by id (the `{bundle_id}` path placeholder keeps the legacy name; responses expose the id as `proof_id`). Scoped to the API key's workspace; proofs in other workspaces (or outside a scoped key's folder allowlist) 404 with the same body as a genuinely-missing id (no enumeration oracle).",
        "operationId": "getAnchor",
        "parameters": [{"$ref": "#/components/parameters/BundleIdPath"}],
        "responses": {
          "200": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/AnchorDetail"}}}, "description": "Proof detail."},
          "401": {"$ref": "#/components/responses/Unauthorized"},
          "403": {"$ref": "#/components/responses/Forbidden"},
          "404": {"$ref": "#/components/responses/ReceiptNotFound"}
        },
        "security": [{"BearerAuth": ["proofs:read"]}],
        "summary": "Get one anchor (proof) by id",
        "tags": ["Anchors"]
      },
      "patch": {
        "description": "Attach (`{annotation: {kind, text, superseded_by_bundle_id?}}`) or clear (`{annotation: null}`) an operator annotation on a proof. The chain anchor itself is unchanged - annotations are workspace metadata only. Requires the `proofs:annotate` scope (legacy spelling `receipts:annotate` also accepted), which is NOT in the default-mint scope set; mint a dedicated key for annotation tooling.",
        "operationId": "annotateAnchor",
        "parameters": [{"$ref": "#/components/parameters/BundleIdPath"}],
        "requestBody": {
          "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AnnotationPatch"}}},
          "required": true
        },
        "responses": {
          "200": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/AnchorDetail"}}}, "description": "Updated proof (same shape as GET /api/v1/anchors/{bundle_id})."},
          "400": {"$ref": "#/components/responses/BadRequest"},
          "401": {"$ref": "#/components/responses/Unauthorized"},
          "403": {"$ref": "#/components/responses/Forbidden"},
          "404": {"$ref": "#/components/responses/AnchorNotFound"}
        },
        "security": [{"BearerAuth": ["proofs:annotate"]}],
        "summary": "Annotate (or clear an annotation on) one anchor",
        "tags": ["Anchors"]
      }
    },
    "/api/v1/anchors/schemes": {
      "get": {
        "description": "Public capability list (no authentication, CORS-open, no rate limit) of the native disclosure schemes supported for content-addressed anchoring with selective redaction and verification. Derived live from the server's scheme registries. Lists only schemes that are creatable, redactable, and verifiable end-to-end today; PDF/image/zip granularities remain deferred and are intentionally not listed.",
        "operationId": "getAnchorSchemes",
        "responses": {
          "200": {
            "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AnchorSchemesResponse"}}},
            "description": "Supported native disclosure schemes."
          },
          "400": {"$ref": "#/components/responses/BadRequest"}
        },
        "summary": "List supported disclosure schemes (public)",
        "tags": ["Anchors"]
      }
    },
    "/api/v1/audit-packet": {
      "get": {
        "description": "Deterministic export of a workspace's proofs as a self-contained zip (Phase 4.4). Same scope + verifier version in -> byte-identical zip out; the packet's sha256 (`X-Audit-Packet-Digest`) is a stable identifier the holder can re-derive offline.\n\nThe underscore alias `/api/v1/audit_packet` is also routed to this handler (history: brief / agent docs repeatedly mis-wrote the URL).",
        "operationId": "getAuditPacket",
        "parameters": [
          {"$ref": "#/components/parameters/FolderSlugFilter"},
          {"$ref": "#/components/parameters/MatterSlugFilter"},
          {"$ref": "#/components/parameters/SessionIdFilter"},
          {"description": "Inclusive lower bound on anchored_at (unix seconds).", "in": "query", "name": "since", "required": false, "schema": {"minimum": 0, "type": "integer"}},
          {"description": "Inclusive upper bound on anchored_at (unix seconds).", "in": "query", "name": "until", "required": false, "schema": {"minimum": 0, "type": "integer"}}
        ],
        "responses": {
          "200": {
            "content": {"application/zip": {"schema": {"format": "binary", "type": "string"}}},
            "description": "Audit packet zip. Filename pattern: `satsignal-audit-<workspace_slug>-<first-8-of-digest>.zip`.",
            "headers": {
              "Content-Disposition": {"schema": {"example": "attachment; filename=\"satsignal-audit-acmecorp-deadbeef.zip\"", "type": "string"}},
              "X-Audit-Packet-Anchors": {"description": "Count of anchors included in the packet.", "schema": {"type": "integer"}},
              "X-Audit-Packet-Digest": {"description": "sha256 of the packet zip bytes; re-derive offline to confirm receipt.", "schema": {"type": "string"}},
              "X-Audit-Packet-Skipped": {"description": "Count of proofs whose canonical bytes were unavailable on disk (retention-evicted etc.). Listed under `handoff.skipped[]` in the packet.", "schema": {"type": "integer"}},
              "X-RateLimit-Limit": {"$ref": "#/components/headers/XRateLimitLimit"},
              "X-RateLimit-Remaining": {"$ref": "#/components/headers/XRateLimitRemaining"},
              "X-RateLimit-Reset": {"$ref": "#/components/headers/XRateLimitReset"},
              "X-RateLimit-Window": {"$ref": "#/components/headers/XRateLimitWindow"}
            }
          },
          "400": {"$ref": "#/components/responses/BadRequest"},
          "401": {"$ref": "#/components/responses/Unauthorized"},
          "403": {"$ref": "#/components/responses/Forbidden"},
          "404": {"$ref": "#/components/responses/FolderNotFound"},
          "413": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}, "description": "Workspace scope exceeded the MAX_ANCHORS cap (1000); error code `packet_too_large`. The packet is withheld (NOT silently truncated and returned) so the export is never incomplete without the caller knowing. Narrow the scope (folder_slug, session_id, or a since/until window) and retry.", "headers": {"X-Audit-Packet-Truncated": {"description": "True count of in-scope anchors (which exceeded the cap), so the caller knows how far to split the export.", "schema": {"type": "integer"}}}}
        },
        "security": [{"BearerAuth": ["proofs:read"]}],
        "summary": "Export workspace proofs as a deterministic audit packet (zip)",
        "tags": ["Audit"]
      }
    },
    "/api/v1/audit_packet": {
      "get": {
        "description": "Legacy alias of /api/v1/audit-packet. Identical handler and response shape. Kept stable; not deprecated.",
        "operationId": "getAuditPacketUnderscore",
        "parameters": [
          {"$ref": "#/components/parameters/FolderSlugFilter"},
          {"$ref": "#/components/parameters/MatterSlugFilter"},
          {"$ref": "#/components/parameters/SessionIdFilter"},
          {"description": "Inclusive lower bound on anchored_at (unix seconds).", "in": "query", "name": "since", "required": false, "schema": {"minimum": 0, "type": "integer"}},
          {"description": "Inclusive upper bound on anchored_at (unix seconds).", "in": "query", "name": "until", "required": false, "schema": {"minimum": 0, "type": "integer"}}
        ],
        "responses": {
          "200": {"content": {"application/zip": {"schema": {"format": "binary", "type": "string"}}}, "description": "Audit packet zip; see /api/v1/audit-packet for headers."},
          "400": {"$ref": "#/components/responses/BadRequest"},
          "401": {"$ref": "#/components/responses/Unauthorized"},
          "403": {"$ref": "#/components/responses/Forbidden"},
          "404": {"$ref": "#/components/responses/FolderNotFound"}
        },
        "security": [{"BearerAuth": ["proofs:read"]}],
        "summary": "Audit packet (underscore alias)",
        "tags": ["Legacy"]
      }
    },
    "/api/v1/folders": {
      "get": {
        "description": "List folders the API key can see. Scoped keys see only folder ids in their allowlist; unscoped keys see every folder in the workspace.",
        "operationId": "listFolders",
        "responses": {
          "200": {
            "content": {"application/json": {"schema": {"$ref": "#/components/schemas/FolderListResponse"}}},
            "description": "Folder listing.",
            "headers": {
              "X-RateLimit-Limit": {"$ref": "#/components/headers/XRateLimitLimit"},
              "X-RateLimit-Remaining": {"$ref": "#/components/headers/XRateLimitRemaining"},
              "X-RateLimit-Reset": {"$ref": "#/components/headers/XRateLimitReset"},
              "X-RateLimit-Window": {"$ref": "#/components/headers/XRateLimitWindow"}
            }
          },
          "400": {"$ref": "#/components/responses/BadRequest"},
          "401": {"$ref": "#/components/responses/Unauthorized"},
          "403": {"$ref": "#/components/responses/Forbidden"}
        },
        "security": [{"BearerAuth": ["proofs:read"]}],
        "summary": "List folders in the workspace",
        "tags": ["Folders"]
      },
      "post": {
        "description": "Create a folder. Keyless retries converge: re-POSTing an identical slug+name returns the existing folder as 200 with `duplicate: true` (nothing is created); a slug collision with a DIFFERENT name returns 409 `duplicate_slug`. Idempotency-Key supported (24h TTL, body-hash match -> verbatim replay; mismatch -> 409). Scoped API keys are rejected (403 `folder_create_forbidden`) - a scoped key creating a fresh folder and anchoring under it would defeat the scope.",
        "operationId": "createFolder",
        "parameters": [{"$ref": "#/components/parameters/IdempotencyKey"}],
        "requestBody": {
          "content": {
            "application/json": {
              "example": {"name": "AcmeCorp events", "slug": "acme-events"},
              "schema": {"$ref": "#/components/schemas/FolderCreateRequest"}
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "content": {"application/json": {"schema": {"$ref": "#/components/schemas/FolderCreateResponse"}}},
            "description": "Identical slug+name re-POST: the existing folder, with top-level `duplicate: true`. Nothing was created.",
            "headers": {
              "X-Idempotent-Replayed": {"$ref": "#/components/headers/XIdempotentReplayed"}
            }
          },
          "201": {
            "content": {"application/json": {"schema": {"$ref": "#/components/schemas/FolderCreateResponse"}}},
            "description": "Folder created.",
            "headers": {
              "X-Idempotent-Replayed": {"$ref": "#/components/headers/XIdempotentReplayed"}
            }
          },
          "400": {"$ref": "#/components/responses/BadRequest"},
          "401": {"$ref": "#/components/responses/Unauthorized"},
          "403": {"$ref": "#/components/responses/Forbidden"},
          "409": {"$ref": "#/components/responses/Conflict"},
          "429": {"$ref": "#/components/responses/QuotaExceeded"}
        },
        "security": [{"BearerAuth": ["anchors:create"]}],
        "summary": "Create a folder",
        "tags": ["Folders"]
      }
    },
    "/api/v1/folders/{slug}/anchors": {
      "get": {
        "description": "Folder-level anchor transparency. Returns every proof ever written for this folder, including soft-deleted rows (chain anchors are permanent - soft-delete must not mask siblings from an audit). Does NOT cross folders.",
        "operationId": "listFolderAnchors",
        "parameters": [{"$ref": "#/components/parameters/SlugPath"}],
        "responses": {
          "200": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/FolderAnchorListResponse"}}}, "description": "Folder + its anchor list."},
          "401": {"$ref": "#/components/responses/Unauthorized"},
          "403": {"$ref": "#/components/responses/Forbidden"},
          "404": {"$ref": "#/components/responses/FolderNotFound"}
        },
        "security": [{"BearerAuth": ["proofs:read"]}],
        "summary": "List anchors in one folder",
        "tags": ["Folders"]
      }
    },
    "/api/v1/matters": {
      "get": {
        "deprecated": true,
        "description": "Legacy alias of /api/v1/folders. Identical handler; still accepted. Responses emit canonical keys only.",
        "operationId": "listMatters",
        "responses": {
          "200": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/FolderListResponse"}}}, "description": "Folder listing."},
          "401": {"$ref": "#/components/responses/Unauthorized"},
          "403": {"$ref": "#/components/responses/Forbidden"}
        },
        "security": [{"BearerAuth": ["proofs:read"]}],
        "summary": "List folders (legacy spelling)",
        "tags": ["Legacy"]
      },
      "post": {
        "deprecated": true,
        "description": "Legacy alias of POST /api/v1/folders. Identical handler; still accepted. Responses emit canonical keys only.",
        "operationId": "createMatter",
        "parameters": [{"$ref": "#/components/parameters/IdempotencyKey"}],
        "requestBody": {
          "content": {"application/json": {"schema": {"$ref": "#/components/schemas/FolderCreateRequest"}}},
          "required": true
        },
        "responses": {
          "201": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/FolderCreateResponse"}}}, "description": "Folder created."},
          "400": {"$ref": "#/components/responses/BadRequest"},
          "401": {"$ref": "#/components/responses/Unauthorized"},
          "403": {"$ref": "#/components/responses/Forbidden"},
          "409": {"$ref": "#/components/responses/Conflict"}
        },
        "security": [{"BearerAuth": ["anchors:create"]}],
        "summary": "Create a folder (legacy spelling)",
        "tags": ["Legacy"]
      }
    },
    "/api/v1/matters/{slug}/anchors": {
      "get": {
        "deprecated": true,
        "description": "Legacy alias of /api/v1/folders/{slug}/anchors. Identical handler; still accepted. Responses emit canonical keys only.",
        "operationId": "listMatterAnchors",
        "parameters": [{"$ref": "#/components/parameters/SlugPath"}],
        "responses": {
          "200": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/FolderAnchorListResponse"}}}, "description": "Folder + anchor list."},
          "401": {"$ref": "#/components/responses/Unauthorized"},
          "403": {"$ref": "#/components/responses/Forbidden"},
          "404": {"$ref": "#/components/responses/FolderNotFound"}
        },
        "security": [{"BearerAuth": ["proofs:read"]}],
        "summary": "List anchors in one folder (legacy spelling)",
        "tags": ["Legacy"]
      }
    },
    "/api/v1/proofs/{bundle_id}": {
      "get": {
        "description": "Canonical proof-detail route. Identical handler and response shape as /api/v1/anchors/{bundle_id}; the `{bundle_id}` path placeholder keeps the legacy name.",
        "operationId": "getProof",
        "parameters": [{"$ref": "#/components/parameters/BundleIdPath"}],
        "responses": {
          "200": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/AnchorDetail"}}}, "description": "Proof detail."},
          "401": {"$ref": "#/components/responses/Unauthorized"},
          "403": {"$ref": "#/components/responses/Forbidden"},
          "404": {"$ref": "#/components/responses/ReceiptNotFound"}
        },
        "security": [{"BearerAuth": ["proofs:read"]}],
        "summary": "Get one proof by proof_id",
        "tags": ["Anchors"]
      }
    },
    "/api/v1/provenance/anchor": {
      "post": {
        "description": "Structured provenance ingest. Two mutually-exclusive request shapes (see ProvenanceAnchorRequest), selected by the fields present — there is no `mode` field:\n\n  - **plaintext**: send the `manifest` object. It is strict-normalized + SCJ-v1-canonicalized server-side (Satsignal Canonical JSON — deliberately not RFC 8785/JCS; see /spec-provenance §3); its sha256 is committed on-chain as a byte_exact standard anchor and the full canonical manifest ships in the bundle (offline-verifiable). Response carries `manifest_hash` plus a ready-to-embed `chain_anchor` envelope.\n  - **sealed**: the client HMAC-blinds the manifest locally and sends ONLY `byte_exact_commitment` (+ optional `salt_b64`); the plaintext manifest NEVER reaches the server. `privacy.onchain_mode:\"sealed\"` lives inside the client-canonicalized manifest bytes, not as a transport flag. See /spec-provenance §6 for the commitment construction. Response `mode` is `provenance_sealed` and carries no `manifest_hash`.\n\nA `manifest` whose `privacy.onchain_mode` is `sealed` on this (plaintext) route is rejected 400 `sealed_manifest_on_plaintext_route`.\n\nThere is NO HTTP validate-only / dry-run input: `dry_run` appears in the RESPONSE but is rejected as a request field (400 `unknown_field`) — a malformed body burns a real anchor, so validate client-side first.\n\nReuses the same auth / quota / dedup-mutex / harness-string machinery as POST /api/v1/anchors.",
        "operationId": "createProvenanceAnchor",
        "requestBody": {
          "content": {
            "application/json": {
              "examples": {
                "plaintext": {"summary": "Plaintext provenance (manifest sent)", "value": {"category": "evidence_bundle", "folder_slug": "acme-events", "label": "ResearchAgent run 42", "manifest": {"created_at_utc": "2026-05-26T00:00:00Z", "kind": "satsignal.provenance.v1", "session_id": "ResearchAgent-2026-05-26"}}},
                "sealed": {"summary": "Sealed provenance (commitment only; manifest never sent)", "value": {"byte_exact_commitment": "deadbeef00000000000000000000000000000000000000000000000000000000", "file_size": 4096, "folder_slug": "acme-events", "label": "ResearchAgent run 42 (sealed)", "retain_days": 7, "salt_b64": "c2FsdHk"}}
              },
              "schema": {"$ref": "#/components/schemas/ProvenanceAnchorRequest"}
            }
          },
          "required": true
        },
        "responses": {
          "200": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ProvenanceAnchorResponse"}}}, "description": "Provenance anchor created."},
          "400": {"$ref": "#/components/responses/BadRequest"},
          "401": {"$ref": "#/components/responses/Unauthorized"},
          "403": {"$ref": "#/components/responses/Forbidden"},
          "404": {"$ref": "#/components/responses/FolderNotFound"},
          "429": {"$ref": "#/components/responses/QuotaExceeded"}
        },
        "security": [{"BearerAuth": ["anchors:create"]}],
        "summary": "Anchor a structured provenance manifest",
        "tags": ["Provenance"]
      }
    },
    "/api/v1/receipts/{bundle_id}": {
      "get": {
        "deprecated": true,
        "description": "Legacy alias of /api/v1/proofs/{bundle_id}. Identical handler; still accepted. Responses emit canonical keys only.",
        "operationId": "getReceipt",
        "parameters": [{"$ref": "#/components/parameters/BundleIdPath"}],
        "responses": {
          "200": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/AnchorDetail"}}}, "description": "Proof detail."},
          "401": {"$ref": "#/components/responses/Unauthorized"},
          "403": {"$ref": "#/components/responses/Forbidden"},
          "404": {"$ref": "#/components/responses/ReceiptNotFound"}
        },
        "security": [{"BearerAuth": ["proofs:read"]}],
        "summary": "Get one proof by id (legacy spelling)",
        "tags": ["Legacy"]
      }
    },
    "/api/v1/keys": {
      "get": {
        "description": "List the keys minted by the CALLING keys:admin key - its mint lineage, with revoked rows included so a sweep sees everything it ever created. Deliberately NOT the whole workspace key list: a leaked admin key cannot enumerate dashboard-minted credentials.",
        "operationId": "listApiKeys",
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiKeyListResponse"
                }
              }
            },
            "description": "Mint lineage for this admin key."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        },
        "security": [
          {
            "BearerAuth": [
              "keys:admin"
            ]
          }
        ],
        "summary": "List keys you minted",
        "tags": [
          "Keys"
        ]
      },
      "post": {
        "description": "Mint a scoped sub-key. Required scope: keys:admin (grantable ONLY on the owner dashboard - the API can never mint a key bearing keys:admin). Requested `scopes` must be a non-empty subset of the minting key's own scopes (minus keys:admin); a folder-scoped minting key bounds its children to the same folders (omitting `folder_slugs` inherits that allowlist). The raw secret is returned ONCE in `secret` and stored only as a sha256 hash. Per-minting-key (default 10/h) and per-workspace (default 30/h) sliding-window mint limits apply.",
        "operationId": "createApiKey",
        "requestBody": {
          "content": {
            "application/json": {
              "example": {
                "name": "acme-corp tenant key",
                "scopes": [
                  "anchors:create",
                  "proofs:read"
                ],
                "folder_slugs": [
                  "acme-events"
                ]
              },
              "schema": {
                "$ref": "#/components/schemas/ApiKeyMintRequest"
              }
            }
          },
          "required": true
        },
        "responses": {
          "201": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiKeyMintResponse"
                }
              }
            },
            "description": "Key minted. `secret` is shown exactly once - store it now.",
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/XRateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/XRateLimitRemaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/XRateLimitReset"
              },
              "X-RateLimit-Window": {
                "$ref": "#/components/headers/XRateLimitWindow"
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "content": {
              "application/json": {
                "example": {
                  "error": {
                    "code": "keys_admin_not_mintable",
                    "message": "keys:admin can only be granted from the workspace dashboard by the owner; an API-minted key can never carry it"
                  }
                }
              },
              "schema": {
                "$ref": "#/components/schemas/ErrorResponse"
              }
            },
            "description": "Missing the keys:admin scope (`insufficient_scope`), an attempt to mint keys:admin (`keys_admin_not_mintable`), or requested scopes exceeding the minting key's own (`scope_escalation`)."
          },
          "404": {
            "$ref": "#/components/responses/FolderNotFound"
          },
          "429": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Per-minting-key or per-workspace mint rate limit hit (code `rate_limited`).",
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/XRateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/XRateLimitRemaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/XRateLimitReset"
              },
              "X-RateLimit-Window": {
                "$ref": "#/components/headers/XRateLimitWindow"
              },
              "Retry-After": {
                "description": "Seconds to wait before retrying.",
                "schema": {
                  "type": "integer"
                }
              }
            }
          }
        },
        "security": [
          {
            "BearerAuth": [
              "keys:admin"
            ]
          }
        ],
        "summary": "Mint a scoped sub-key",
        "tags": [
          "Keys"
        ]
      }
    },
    "/api/v1/keys/{id}": {
      "delete": {
        "description": "Revoke a key the CALLING admin key minted. Idempotent 204 (a re-DELETE of an already-revoked child is still 204). Everything else - unknown id, a dashboard-minted key, another admin key's mint, another workspace's key - is one uniform 404 `not_found` (anti-enumeration). Revoking an admin key does NOT cascade automatically; the dashboard lineage tags are the manual sweep tool (decision 0047).",
        "operationId": "revokeApiKey",
        "parameters": [
          {
            "description": "Id of a key minted by the calling admin key.",
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "pattern": "^[A-Za-z0-9_-]+$",
              "type": "string"
            }
          }
        ],
        "responses": {
          "204": {
            "description": "Revoked (idempotent)."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        },
        "security": [
          {
            "BearerAuth": [
              "keys:admin"
            ]
          }
        ],
        "summary": "Revoke a key you minted",
        "tags": [
          "Keys"
        ]
      }
    },
    "/api/v1/usage": {
      "get": {
        "description": "Read-only quota / usage status for the API key's workspace. Same numbers the X-RateLimit-* headers carry, plus the current plan + period boundary so a finance team can reconcile against the workspace's usage_events table.",
        "operationId": "getUsage",
        "responses": {
          "200": {
            "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UsageResponse"}}},
            "description": "Quota envelope.",
            "headers": {
              "X-RateLimit-Limit": {"$ref": "#/components/headers/XRateLimitLimit"},
              "X-RateLimit-Remaining": {"$ref": "#/components/headers/XRateLimitRemaining"},
              "X-RateLimit-Reset": {"$ref": "#/components/headers/XRateLimitReset"},
              "X-RateLimit-Window": {"$ref": "#/components/headers/XRateLimitWindow"}
            }
          },
          "400": {"$ref": "#/components/responses/BadRequest"},
          "401": {"$ref": "#/components/responses/Unauthorized"},
          "403": {"$ref": "#/components/responses/Forbidden"}
        },
        "security": [{"BearerAuth": ["proofs:read"]}],
        "summary": "Get workspace quota / usage status",
        "tags": ["Usage"]
      }
    },
    "/api/v1/webhooks": {
      "get": {
        "description": "List webhook configs in the API key's workspace. Scoped keys see only configs whose folder is in their scope. Paginated by `created_at` DESC.",
        "operationId": "listWebhooks",
        "parameters": [
          {"$ref": "#/components/parameters/LimitWebhooks"},
          {"$ref": "#/components/parameters/Before"},
          {"$ref": "#/components/parameters/MatterSlugFilter"},
          {"$ref": "#/components/parameters/FolderSlugFilter"}
        ],
        "responses": {
          "200": {
            "content": {"application/json": {"schema": {"$ref": "#/components/schemas/WebhookListResponse"}}},
            "description": "Webhook listing.",
            "headers": {
              "X-RateLimit-Limit": {"$ref": "#/components/headers/XRateLimitLimit"},
              "X-RateLimit-Remaining": {"$ref": "#/components/headers/XRateLimitRemaining"},
              "X-RateLimit-Reset": {"$ref": "#/components/headers/XRateLimitReset"},
              "X-RateLimit-Window": {"$ref": "#/components/headers/XRateLimitWindow"}
            }
          },
          "400": {"$ref": "#/components/responses/BadRequest"},
          "401": {"$ref": "#/components/responses/Unauthorized"},
          "403": {"$ref": "#/components/responses/Forbidden"},
          "404": {"$ref": "#/components/responses/FolderNotFound"}
        },
        "security": [{"BearerAuth": ["anchors:create"]}],
        "summary": "List webhook configs",
        "tags": ["Webhooks"]
      },
      "post": {
        "description": "Create a webhook config that anchors inbound deliveries against a folder. For `source_type=none` and `source_type=github`, the server generates (or accepts caller-supplied) a signing secret and returns it ONE-TIME in `secret` (unrecoverable afterwards). For `source_type=stripe` or `source_type=langfuse`, the secret is supplied separately at create or via PATCH.\n\nIdempotency-Key supported (same shape as POST /api/v1/anchors / /folders). Replay returns the verbatim cached 201 - INCLUDING the one-shot secret - so a retry never mints a duplicate config with a fresh secret.",
        "operationId": "createWebhook",
        "parameters": [{"$ref": "#/components/parameters/IdempotencyKey"}],
        "requestBody": {
          "content": {
            "application/json": {
              "example": {"folder_slug": "acme-events", "label": "AcmeCorp Stripe events", "source_type": "stripe"},
              "schema": {"$ref": "#/components/schemas/WebhookCreateRequest"}
            }
          },
          "required": true
        },
        "responses": {
          "201": {
            "content": {"application/json": {"schema": {"$ref": "#/components/schemas/WebhookCreateResponse"}}},
            "description": "Webhook created.",
            "headers": {
              "X-Idempotent-Replayed": {"$ref": "#/components/headers/XIdempotentReplayed"}
            }
          },
          "400": {"$ref": "#/components/responses/BadRequest"},
          "401": {"$ref": "#/components/responses/Unauthorized"},
          "403": {"$ref": "#/components/responses/Forbidden"},
          "404": {"$ref": "#/components/responses/FolderNotFound"}
        },
        "security": [{"BearerAuth": ["anchors:create"]}],
        "summary": "Create a webhook config",
        "tags": ["Webhooks"]
      }
    },
    "/api/v1/webhooks/{webhook_id}": {
      "delete": {
        "description": "Soft-revoke a webhook config. Chain anchors written via this config are untouched.",
        "operationId": "deleteWebhook",
        "parameters": [{"$ref": "#/components/parameters/WebhookIdPath"}],
        "responses": {
          "204": {"description": "Revoked."},
          "401": {"$ref": "#/components/responses/Unauthorized"},
          "403": {"$ref": "#/components/responses/Forbidden"},
          "404": {"$ref": "#/components/responses/WebhookNotFound"},
          "410": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}, "description": "Already revoked."}
        },
        "security": [{"BearerAuth": ["anchors:create"]}],
        "summary": "Revoke (soft-delete) a webhook config",
        "tags": ["Webhooks"]
      },
      "get": {
        "description": "Fetch one webhook config. Workspace-scoped - a key holder cannot look up configs from other workspaces.",
        "operationId": "getWebhook",
        "parameters": [{"$ref": "#/components/parameters/WebhookIdPath"}],
        "responses": {
          "200": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/WebhookResource"}}}, "description": "Webhook config."},
          "401": {"$ref": "#/components/responses/Unauthorized"},
          "403": {"$ref": "#/components/responses/Forbidden"},
          "404": {"$ref": "#/components/responses/WebhookNotFound"}
        },
        "security": [{"BearerAuth": ["anchors:create"]}],
        "summary": "Get one webhook config",
        "tags": ["Webhooks"]
      },
      "patch": {
        "description": "Update a webhook config. Body allowlist: `{signing_secret, label, revoked}`. `revoked: true` revokes; `revoked: false` is rejected (un-revoke is intentionally not exposed - mint a new config to rotate).",
        "operationId": "patchWebhook",
        "parameters": [{"$ref": "#/components/parameters/WebhookIdPath"}],
        "requestBody": {
          "content": {"application/json": {"schema": {"$ref": "#/components/schemas/WebhookPatchRequest"}}},
          "required": true
        },
        "responses": {
          "200": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/WebhookResource"}}}, "description": "Updated webhook config."},
          "400": {"$ref": "#/components/responses/BadRequest"},
          "401": {"$ref": "#/components/responses/Unauthorized"},
          "403": {"$ref": "#/components/responses/Forbidden"},
          "404": {"$ref": "#/components/responses/WebhookNotFound"},
          "410": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}, "description": "Config has been revoked."}
        },
        "security": [{"BearerAuth": ["anchors:create"]}],
        "summary": "Update a webhook config",
        "tags": ["Webhooks"]
      },
      "post": {
        "description": "Receive a signed event from a configured SaaS source and anchor the body bytes against the config's folder. **No Authorization header** - the URL-encoded `webhook_id` is the router, and the per-source signature scheme authenticates the payload.\n\nSecurity model: `WebhookSignatureAuth`. For `source_type=none` the signature is HMAC-SHA256(secret, '<unix-timestamp>.' + body) in `X-Satsignal-Signature`. For `source_type=stripe|github|langfuse` the source's native signature scheme is verified (Stripe-Signature, X-Hub-Signature-256, Langfuse webhook signature, respectively).\n\nEnumeration defense: unknown id, revoked config, not-yet-provisioned secret, and bad signature ALL collapse to the same generic 401 `signature_mismatch` response. The per-wh_id rate limit (60/60s) is keyed on the URL id and applied identically to real, unknown, and revoked ids so 401-vs-429 cannot distinguish them.\n\nWebhook deliveries dedup on body sha256 (Stripe retries within the 5-min replay window arrive with the same body + same valid signature; we MUST NOT broadcast again on a legitimate retry).",
        "operationId": "receiveWebhook",
        "parameters": [{"$ref": "#/components/parameters/WebhookIdPath"}],
        "requestBody": {
          "content": {"application/octet-stream": {"schema": {"format": "binary", "type": "string"}}},
          "description": "Raw bytes of the source's webhook delivery. Capped at 1 MiB (and ~64 KiB at the proxy on app.satsignal.cloud).",
          "required": true
        },
        "responses": {
          "200": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/WebhookReceiveResponse"}}}, "description": "Anchored (or dedup-hit on a Stripe-style retry)."},
          "400": {"$ref": "#/components/responses/BadRequest"},
          "401": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}, "description": "Signature mismatch (or unknown / revoked / unprovisioned id - collapsed for enumeration defense)."},
          "429": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/RateLimitedResponse"}}}, "description": "Per-webhook delivery rate limit hit (60/60s).", "headers": {"Retry-After": {"description": "Seconds to wait before retrying.", "schema": {"type": "integer"}}}},
          "503": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}, "description": "Workspace or folder no longer available."}
        },
        "security": [{"WebhookSignatureAuth": []}],
        "summary": "Receive a signed webhook delivery from the configured source",
        "tags": ["Webhooks"]
      }
    },
    "/bundle/{bundle_id}.mbnt": {
      "get": {
        "description": "Bearer-auth bundle download. Streams the .mbnt zip after confirming the proof belongs to the API key's workspace.\n\nSealed-blind responses don't persist a server-side bundle - the .mbnt is assembled client-side from `canonical_b64` at anchor time. Those proofs return 404 `bundle_not_persisted` here so callers can distinguish 'you never gave us this bundle' from 'we don't recognize you'.",
        "operationId": "downloadBundle",
        "parameters": [{"$ref": "#/components/parameters/BundleIdPath"}],
        "responses": {
          "200": {
            "content": {"application/zip": {"schema": {"format": "binary", "type": "string"}}},
            "description": "The .mbnt bundle zip.",
            "headers": {
              "Content-Disposition": {"schema": {"example": "attachment; filename=\"acmecorp-run42.mbnt\"", "type": "string"}}
            }
          },
          "401": {"$ref": "#/components/responses/Unauthorized"},
          "403": {"$ref": "#/components/responses/Forbidden"},
          "404": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}, "description": "`proof_not_found` or `bundle_not_persisted` (sealed-blind)."}
        },
        "security": [{"BearerAuth": ["proofs:read"]}],
        "summary": "Download a .mbnt bundle (bearer-auth)",
        "tags": ["Bundle"]
      }
    },
    "/lookup_hash": {
      "get": {
        "description": "Public hash-existence oracle. NO auth, CORS-open (Access-Control-Allow-Origin: *), rate-limited 120 GETs per IP per hour (configurable via SATSIGNAL_NOTARY_LOOKUP_HASH_LIMIT). Only indexes the ORIGINAL-FILE sha256 (the `sha256_hex` submitted when anchoring) from standard-mode sidecars; merkle roots, sealed commitments, and commit-doc SHAs all fall through to miss.\n\nExactly one of `sha`, `sha256_hex`, or `sha256` must be supplied as a query parameter — they are aliases for the same value. Several supplied with identical values is accepted; differing values return `400 conflicting_alias` (the same convention `/api/v1/anchors` uses for `folder_slug` vs its legacy `matter_slug` alias).\n\nUsed by third-party verifier UIs (regulator portals, embedded widgets) without proxying.",
        "operationId": "lookupHash",
        "parameters": [
          {"description": "Full 64-character lowercase sha256 of the original file — the `sha256_hex` submitted when anchoring. Case is folded to lowercase server-side. One of `sha`, `sha256_hex`, or `sha256` must be supplied; supplying several is permitted only if values match.", "in": "query", "name": "sha", "required": false, "schema": {"pattern": "^[0-9a-fA-F]{64}$", "type": "string"}},
          {"description": "Alias for `sha`, matching the JSON request-body field name used elsewhere in the API. One of `sha`, `sha256_hex`, or `sha256` must be supplied; supplying several is permitted only if values match.", "in": "query", "name": "sha256_hex", "required": false, "schema": {"pattern": "^[0-9a-fA-F]{64}$", "type": "string"}},
          {"description": "Alias for `sha` (shortened spelling integrators reach for). One of `sha`, `sha256_hex`, or `sha256` must be supplied; supplying several is permitted only if values match.", "in": "query", "name": "sha256", "required": false, "schema": {"pattern": "^[0-9a-fA-F]{64}$", "type": "string"}}
        ],
        "responses": {
          "200": {"content": {"application/json": {"schema": {"oneOf": [{"$ref": "#/components/schemas/LookupHashHit"}, {"$ref": "#/components/schemas/LookupHashMiss"}]}}}, "description": "Hit (with `proof_id` + `txid` + `created_utc`) or miss (with `miss: true` + `reason`).", "headers": {"Access-Control-Allow-Origin": {"schema": {"example": "*", "type": "string"}}}},
          "400": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}, "description": "`missing_sha`, `missing_or_invalid_sha`, or `conflicting_alias`."},
          "429": {"content": {"application/json": {"schema": {"example": {"error": "rate_limited", "retry_after": 60}, "type": "object"}}}, "description": "Per-IP rate limit exceeded.", "headers": {"Retry-After": {"schema": {"type": "integer"}}}}
        },
        "summary": "Look up a sha256 in the public hash-existence index",
        "tags": ["Public"]
      }
    }
  },
  "components": {
    "headers": {
      "IdempotencyKey": {
        "description": "Optional client-supplied key for at-most-once semantics. 24h TTL, per-(workspace, key) scope. Same key + identical body returns the verbatim cached response with `X-Idempotent-Replayed: true` (no broadcast, no quota tick). Same key + different body returns 409 `idempotency_key_reuse_body_mismatch`.",
        "schema": {"type": "string"}
      },
      "XIdempotentReplayed": {
        "description": "Present (value: `true`) only on responses served from the Idempotency-Key cache; absent on fresh-broadcast responses (which are otherwise byte-identical).",
        "schema": {"enum": ["true"], "type": "string"}
      },
      "XRateLimitLimit": {
        "description": "Workspace plan's anchor quota for the current window.",
        "schema": {"type": "integer"}
      },
      "XRateLimitRemaining": {
        "description": "Anchors remaining in the current window after this request.",
        "schema": {"minimum": 0, "type": "integer"}
      },
      "XRateLimitReset": {
        "description": "Unix timestamp at which the quota window resets (GitHub / Stripe convention).",
        "schema": {"type": "integer"}
      },
      "XRateLimitWindow": {
        "description": "Quota window: `day` (legacy plans) or `month` (new tiers).",
        "schema": {"enum": ["day", "month"], "type": "string"}
      }
    },
    "parameters": {
      "Before": {
        "description": "Pagination cursor - return rows strictly older than this unix timestamp. Pass `before=next_cursor` from the prior page until `next_cursor` is null.",
        "in": "query",
        "name": "before",
        "required": false,
        "schema": {"minimum": 0, "type": "integer"}
      },
      "BundleIdPath": {
        "description": "16-char lowercase hex proof id. The path placeholder name `bundle_id` is a deliberate, permanently frozen exception to the folder/proof vocabulary - kept for URL/wire stability, NOT a pending rename - while every response exposes the id as the canonical `proof_id`.",
        "in": "path",
        "name": "bundle_id",
        "required": true,
        "schema": {"pattern": "^[0-9a-f]{8,32}$", "type": "string"}
      },
      "FolderSlugFilter": {
        "description": "Restrict to one folder by slug (canonical filter). Unknown slug -> 404 `folder_not_found`.",
        "in": "query",
        "name": "folder_slug",
        "required": false,
        "schema": {"type": "string"}
      },
      "IdempotencyKey": {
        "description": "Optional at-most-once key. See components/headers/IdempotencyKey for semantics.",
        "in": "header",
        "name": "Idempotency-Key",
        "required": false,
        "schema": {"type": "string"}
      },
      "Limit": {
        "description": "Page size. Default 100, capped at 1000.",
        "in": "query",
        "name": "limit",
        "required": false,
        "schema": {"default": 100, "maximum": 1000, "minimum": 1, "type": "integer"}
      },
      "LimitWebhooks": {
        "description": "Page size. Default 100, capped at 1000.",
        "in": "query",
        "name": "limit",
        "required": false,
        "schema": {"default": 100, "maximum": 1000, "minimum": 1, "type": "integer"}
      },
      "MatterSlugFilter": {
        "deprecated": true,
        "description": "Legacy alias of `folder_slug`, still accepted. Unknown slug -> 404 `folder_not_found`.",
        "in": "query",
        "name": "matter_slug",
        "required": false,
        "schema": {"type": "string"}
      },
      "SessionIdFilter": {
        "description": "Restrict to anchors tagged with this exact `session_id` (off-chain operator grouping key from POST /api/v1/anchors).",
        "in": "query",
        "name": "session_id",
        "required": false,
        "schema": {"type": "string"}
      },
      "SlugPath": {
        "description": "Folder slug (2-64 chars).",
        "in": "path",
        "name": "slug",
        "required": true,
        "schema": {"maxLength": 64, "minLength": 2, "type": "string"}
      },
      "WebhookIdPath": {
        "description": "Webhook config id (prefixed `wh_`).",
        "in": "path",
        "name": "webhook_id",
        "required": true,
        "schema": {"pattern": "^wh_[A-Za-z0-9_-]+$", "type": "string"}
      }
    },
    "responses": {
      "AnchorNotFound": {
        "content": {"application/json": {"example": {"error": {"code": "anchor_not_found", "message": "no proof with that id in this workspace"}}, "schema": {"$ref": "#/components/schemas/ErrorResponse"}}},
        "description": "No anchor with that id in this workspace (or outside a scoped key's folder allowlist - same response shape for enumeration defense)."
      },
      "BadRequest": {
        "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}},
        "description": "Validation error. Common error codes: `bad_body`, `invalid_content_type`, `invalid_type`, `unknown_field`, `unknown_query_field`, `mode_conflict`, `missing_folder_slug`, `invalid_mode`, `invalid_category`, `invalid_session_id`, `rejected_field`, `anchor_failed`, `invalid_sha256`, `conflicting_alias`, `bad_limit`, `bad_before`, `bad_session_id`, `bad_since`, `bad_until`, `slug_too_short`, `name_too_short`, `name_too_long`, `missing_slug`, `missing_name`, `invalid_source_type`, `invalid_value`, `empty_patch`, `missing_annotation`, `bad_annotation`, `annotation_failed`, `invalid_manifest`, `audit_packet_failed`."
      },
      "Conflict": {
        "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}},
        "description": "State conflict. Common error codes: `idempotency_key_reuse_body_mismatch` (same Idempotency-Key + different body), `proof_set_requires_force_new` (prior anchor for this sha exists + caller sent new proof_set without force_new), `duplicate_slug` (folder slug already taken by a folder with a different name; an identical slug+name re-POST returns 200 with `duplicate: true` instead).\n\nBroadcast-intent conflicts on POST /api/v1/anchors, returned when a prior attempt for this exact request identity has not reached a replayable terminal state: `anchor_in_progress` (an identical request is currently being processed by a concurrent in-flight sibling - RETRYABLE: wait briefly and retry the same body); `anchor_finalization_pending` (the request already broadcast on-chain - the `message` carries the txid - but finalization was interrupted and is being reconciled - do NOT re-submit: re-submitting risks a duplicate spend, poll the proof instead); `anchor_under_review` (a prior attempt could not be confirmed on-chain and is under operator review - do NOT re-submit: the outcome is being resolved manually). Retry semantics: only `anchor_in_progress` is safe to retry; `anchor_finalization_pending` and `anchor_under_review` are terminal-until-resolved and must not be re-submitted."
      },
      "Forbidden": {
        "content": {"application/json": {"example": {"error": {"code": "insufficient_scope", "message": "key is missing required scope: anchors:create"}}, "schema": {"$ref": "#/components/schemas/ErrorResponse"}}},
        "description": "Key lacks the required scope, or a scoped key attempted folder creation (`folder_create_forbidden`)."
      },
      "FolderNotFound": {
        "content": {"application/json": {"example": {"error": {"code": "folder_not_found", "message": "no folder with slug 'acme-events' in this workspace"}}, "schema": {"$ref": "#/components/schemas/ErrorResponse"}}},
        "description": "Folder slug doesn't exist in this workspace (or is outside a scoped key's allowlist - same response shape for enumeration defense)."
      },
      "NotFound": {
        "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}},
        "description": "Resource not found."
      },
      "QuotaExceeded": {
        "content": {"application/json": {"schema": {"$ref": "#/components/schemas/QuotaExceededResponse"}}},
        "description": "Workspace quota exhausted for the current window.",
        "headers": {
          "X-RateLimit-Limit": {"$ref": "#/components/headers/XRateLimitLimit"},
          "X-RateLimit-Remaining": {"$ref": "#/components/headers/XRateLimitRemaining"},
          "X-RateLimit-Reset": {"$ref": "#/components/headers/XRateLimitReset"},
          "X-RateLimit-Window": {"$ref": "#/components/headers/XRateLimitWindow"}
        }
      },
      "ReceiptNotFound": {
        "content": {"application/json": {"example": {"error": {"code": "proof_not_found", "message": "no proof with that id in this workspace"}}, "schema": {"$ref": "#/components/schemas/ErrorResponse"}}},
        "description": "No proof with that id in this workspace (or outside a scoped key's folder allowlist - same response shape for enumeration defense)."
      },
      "Unauthorized": {
        "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}},
        "description": "Authentication failed. Common error codes: `missing_bearer` (Authorization header missing/malformed), `invalid_key` (unknown / revoked / malformed)."
      },
      "WebhookNotFound": {
        "content": {"application/json": {"example": {"error": {"code": "webhook_not_found", "message": "no webhook config with that id in this workspace"}}, "schema": {"$ref": "#/components/schemas/ErrorResponse"}}},
        "description": "No webhook config with that id in this workspace (or outside a scoped key's folder allowlist - same response shape for enumeration defense)."
      }
    },
    "schemas": {
      "MatchLocalRequest": {
        "type": "object",
        "required": [
          "candidates"
        ],
        "properties": {
          "candidates": {
            "type": "array",
            "minItems": 1,
            "maxItems": 8,
            "items": {
              "type": "string",
              "pattern": "^[0-9a-f]{64}$"
            },
            "description": "1–8 lowercase 64-hex digests computed locally from the original file: the raw SHA-256 (standard proofs) and/or manifest merkle root(s). The file bytes are never sent."
          }
        }
      },
      "MatchLocalResult": {
        "description": "Either a hit (the matched proof) or a uniform miss envelope.",
        "oneOf": [
          {
            "type": "object",
            "title": "Hit",
            "required": [
              "proof_id",
              "mode"
            ],
            "properties": {
              "proof_id": {
                "type": "string",
                "description": "The matched proof's canonical id."
              },
              "txid": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "On-chain transaction id, if broadcast."
              },
              "mode": {
                "type": "string",
                "enum": [
                  "standard",
                  "manifest"
                ]
              },
              "created_utc": {
                "type": "integer",
                "description": "Anchored-at, unix seconds."
              },
              "label": {
                "type": [
                  "string",
                  "null"
                ]
              },
              "folder_slug": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "Owning folder slug."
              }
            }
          },
          {
            "type": "object",
            "title": "Miss",
            "required": [
              "miss",
              "reason"
            ],
            "properties": {
              "miss": {
                "type": "boolean"
              },
              "reason": {
                "type": "string",
                "examples": [
                  "not_found_in_workspace"
                ]
              }
            }
          }
        ]
      },
      "AnchorDetail": {
        "description": "Single-proof response. Same shape returned by GET /api/v1/proofs/{bundle_id}, GET /api/v1/anchors/{bundle_id}, PATCH /api/v1/anchors/{bundle_id}, and GET /api/v1/receipts/{bundle_id} (legacy alias). 2xx bodies emit canonical keys only: the wire renames the legacy `bundle_id` / `receipt_url` / `matter_*` fields to `proof_id` / `proof_url` / `folder_*` at the response chokepoint, so the legacy spellings never appear here.",
        "properties": {
          "annotation": {"$ref": "#/components/schemas/AnnotationBlock"},
          "anchored_at": {"description": "Unix seconds of broadcast.", "type": "integer"},
          "bundle_url": {"description": "Bearer-auth `.mbnt` download URL, or null if no server-side bundle exists (sealed-blind).", "format": "uri", "type": ["string", "null"]},
          "category": {"type": "string"},
          "folder_anchor_count": {"description": "Sibling-anchor count for this folder (includes soft-deleted - chain anchors are permanent).", "type": "integer"},
          "folder_anchors_url": {"description": "Bearer-auth listing URL for the owning folder (/api/v1/folders/{slug}/anchors).", "format": "uri", "type": ["string", "null"]},
          "folder_id": {"type": "string"},
          "folder_name": {"type": ["string", "null"]},
          "folder_slug": {"type": ["string", "null"]},
          "label": {"type": ["string", "null"]},
          "manifest": {"$ref": "#/components/schemas/ManifestBlock"},
          "mode": {"$ref": "#/components/schemas/AnchorMode"},
          "proof_id": {"description": "Canonical proof id (path params still accept it under the legacy `bundle_id` placeholder).", "type": "string"},
          "proof_kind": {"description": "Additive, read-only (disclosure-v1 §10). Canonical name for the field formerly emitted as `receipt_kind`. Inferred at read time from `manifest.disclosure` presence in the stored bundle: `disclosure` when present, else `standard`. Not required - legacy proofs anchored before disclosure-v1 may omit it, and a consumer MUST treat absence as `standard`. A SEPARATE axis from `mode`: a disclosure proof's `mode` is still the mode its underlying anchor was committed under (typically `standard`); `mode` is never overloaded with a `disclosure` value.", "enum": ["standard", "disclosure"], "type": "string"},
          "proof_url": {"description": "Canonical proof page URL.", "format": "uri", "type": ["string", "null"]},
          "retain_until": {"description": "Unix timestamp at which the server-side bundle will be eligible for deletion, when an explicit retain_days window was chosen. null for a sealed mirror retained indefinitely (the default — kept until the proof is deleted). Absent for sealed-blind responses.", "type": ["integer", "null"]},
          "txid": {"description": "BSV transaction id, or null for dry-run.", "type": ["string", "null"]}
        },
        "required": ["proof_id", "mode", "category", "label", "anchored_at", "folder_id"],
        "type": "object"
      },
      "AnchorListResponse": {
        "description": "Workspace-wide anchor listing response. `anchor_count` is the size of this page; `workspace_total` is the total across the workspace (or matching the session_id filter, when set).",
        "properties": {
          "anchor_count": {"type": "integer"},
          "anchors": {"items": {"$ref": "#/components/schemas/AnchorListItem"}, "type": "array"},
          "before": {"type": ["integer", "null"]},
          "folder_slug": {"description": "Echo of the folder filter (when applied); null otherwise.", "type": ["string", "null"]},
          "limit": {"type": "integer"},
          "next_cursor": {"description": "Pass to `before=` for the next page; null on the final page.", "type": ["integer", "null"]},
          "note": {"type": "string"},
          "session_id": {"type": ["string", "null"]},
          "workspace": {"$ref": "#/components/schemas/WorkspaceRef"},
          "workspace_total": {"type": "integer"}
        },
        "required": ["workspace", "anchor_count", "workspace_total", "limit", "next_cursor", "anchors"],
        "type": "object"
      },
      "AnchorListItem": {
        "description": "One row in the workspace-wide or folder-scoped anchor listings.",
        "properties": {
          "anchored_at": {"type": "integer"},
          "annotation": {"$ref": "#/components/schemas/AnnotationBlock"},
          "bundle_url": {"format": "uri", "type": ["string", "null"]},
          "category": {"type": "string"},
          "deleted": {"type": "boolean"},
          "deleted_at": {"type": ["integer", "null"]},
          "folder_id": {"type": ["string", "null"]},
          "folder_name": {"type": ["string", "null"]},
          "folder_slug": {"type": ["string", "null"]},
          "label": {"type": ["string", "null"]},
          "mode": {"$ref": "#/components/schemas/AnchorMode"},
          "proof_id": {"description": "Canonical proof id.", "type": "string"},
          "proof_url": {"description": "Canonical proof page URL.", "format": "uri", "type": ["string", "null"]},
          "retain_until": {"type": ["integer", "null"]},
          "session_id": {"type": ["string", "null"]},
          "txid": {"type": ["string", "null"]}
        },
        "required": ["proof_id", "mode", "label", "anchored_at"],
        "type": "object"
      },
      "AnchorMode": {
        "description": "Proof mode. `manifest` is set when the sidecar identifies the proof as a manifest; `provenance` is set by POST /api/v1/provenance/anchor.",
        "enum": ["standard", "sealed", "manifest", "provenance"],
        "type": "string"
      },
      "AnchorRequestCommon": {
        "description": "Common fields across every anchor-request mode.",
        "properties": {
          "category": {"description": "Optional category tag. Closed enum: `output` (the default, applied server-side), `commitment`, `policy_snapshot`, `evidence_bundle`, `memory_checkpoint`, `document` (a neutral tag for contracts, reports, and other document-shaped anchors). One alias accepted: `evidence_manifest` normalizes to `evidence_bundle`. Anything else returns `400 invalid_category` with the full enum in the error detail.", "type": "string"},
          "folder_slug": {"description": "Folder slug (target of the anchor). Required - send `folder_slug` (canonical) or its legacy alias `matter_slug`; sending both with different values returns 400 `conflicting_alias`.", "maxLength": 128, "minLength": 2, "type": "string"},
          "label": {"description": "Free-text label (rejected if it contains an LLM-harness control substring; max 256 chars).", "maxLength": 256, "type": "string"},
          "matter_slug": {"deprecated": true, "description": "Legacy alias of `folder_slug`, still accepted.", "maxLength": 128, "minLength": 2, "type": "string"},
          "mode": {"description": "Mode discriminator. Defaults to `standard`. Manifest mode is auto-selected when `items[]` is present in the body; sending `mode:'manifest'` without items[] (or items[] with `mode:'sealed'`) returns 400 `mode_conflict`. A sealed manifest uses `mode:'sealed'` with `proof_set.chunk_merkle` (not `items[]`); `mode` is a wire discriminator, not a privacy taxonomy.", "enum": ["standard", "sealed", "manifest"], "type": "string"},
          "session_id": {"description": "Off-chain operator-supplied grouping key for cross-anchor agent-session correlation (see GET /api/v1/anchors?session_id=).", "type": "string"}
        },
        "type": "object"
      },
      "AnchorRequestManifest": {
        "allOf": [
          {"$ref": "#/components/schemas/AnchorRequestCommon"},
          {
            "properties": {
              "items": {"description": "Array of `{label, sha256_hex}` leaves committed under a single Merkle root. Empty array is rejected.", "items": {"$ref": "#/components/schemas/ManifestItem"}, "maxItems": 10000, "minItems": 1, "type": "array"}
            },
            "required": ["items"],
            "type": "object"
          }
        ],
        "description": "Manifest-backed anchor (standard privacy): commit a Merkle root over an items[] array. Detection is by `items[]` presence; do NOT also send `sha256_hex` / `file_size` / `mode:'sealed'`. For a SEALED manifest, use `mode:'sealed'` with `proof_set.chunk_merkle` instead (see AnchorRequestSealed); the `items[]` path is standard privacy only."
      },
      "AnchorRequestSealed": {
        "allOf": [
          {"$ref": "#/components/schemas/AnchorRequestCommon"},
          {
            "properties": {
              "byte_exact_commitment": {"description": "Lowercase 64-char hex HMAC commitment of the file bytes (sealed canonical doc carries `commitment`, not `hash`).", "pattern": "^[0-9a-f]{64}$", "type": "string"},
              "file_size": {"description": "File size in bytes. Stays top-level for the wire layer; does NOT enter the on-chain envelope. Must be a JSON integer; boolean rejected with 400 `invalid_type`.", "minimum": 0, "type": "integer"},
              "filename": {"description": "Basename only (sanitized server-side). Max 255 chars.", "maxLength": 255, "type": "string"},
              "mode": {"const": "sealed", "type": "string"},
              "proof_leaves": {"$ref": "#/components/schemas/ProofLeavesSealed"},
              "proof_set": {"$ref": "#/components/schemas/ProofSetSealed"},
              "retain_days": {"description": "Optional explicit auto-delete window (days) for the salt-bearing mirror bundle. OMIT it (the default) and the server retains the bundle indefinitely — until the proof is deleted — with `retain_until: null` in the response. An explicit value >= 1 is honored as given on every plan (no plan-tier ceiling; only an ~100-year arithmetic sanity clamp at 36500). retain_days=0 is valid ONLY in blind mode (omit salt_b64): a salt-bearing retain_days=0 is rejected with 400 `retain_days: 0 requires blind mode — omit salt_b64` (NOT clamped to 1, which would retain a salt the caller asked to drop). Must be a JSON integer; boolean rejected with 400 `invalid_type`.", "maximum": 36500, "minimum": 0, "type": "integer"},
              "salt_b64": {"description": "Base64 salt. Absent (or empty) selects blind sealed mode (no salt reaches the server; response carries `canonical_b64` + `doc_hash` so the client assembles the .mbnt locally). When omitted, `retain_days` must be 0 / absent; when present, `retain_days` must be >= 1.", "type": "string"}
            },
            "required": ["mode", "byte_exact_commitment"],
            "type": "object"
          }
        ],
        "description": "Sealed mode: commit a `byte_exact_commitment` (HMAC) for a salt-bearing or blind file. Per-mode allowed keys: `folder_slug, mode, label, category, session_id, salt_b64, byte_exact_commitment, retain_days, file_size, filename, proof_set, proof_leaves` (legacy `matter_slug` also accepted)."
      },
      "AnchorRequestStandard": {
        "allOf": [
          {"$ref": "#/components/schemas/AnchorRequestCommon"},
          {
            "properties": {
              "file_size": {"description": "File size in bytes. Must be a JSON integer; boolean rejected with 400 `invalid_type`.", "minimum": 0, "type": "integer"},
              "filename": {"description": "Basename only (sanitized server-side). Max 255 chars.", "maxLength": 255, "type": "string"},
              "force_new": {"description": "Opt out of default-dedup (anchor again even if this sha exists in the folder; counts against quota). Required when supplying a richer `proof_set` for an already-anchored sha (server returns 409 `proof_set_requires_force_new` otherwise).", "type": "boolean"},
              "mode": {"const": "standard", "type": "string"},
              "proof_leaves": {"$ref": "#/components/schemas/ProofLeavesStandard"},
              "proof_set": {"$ref": "#/components/schemas/ProofSetStandard"},
              "sha256_hex": {"description": "Lowercase 64-char hex sha256 of the file bytes.", "pattern": "^[0-9a-f]{64}$", "type": "string"}
            },
            "required": ["sha256_hex", "file_size"],
            "type": "object"
          }
        ],
        "description": "Standard mode: commit `sha256_hex + file_size` for a single file. Per-mode allowed keys: `folder_slug, mode, label, category, session_id, sha256_hex, file_size, filename, force_new, proof_set, proof_leaves` (legacy `matter_slug` also accepted)."
      },
      "AnchorResponse": {
        "description": "POST /api/v1/anchors response envelope. 2xx bodies emit canonical keys only: `proof_id`, `proof_url`, `folder_slug` - the legacy `bundle_id` / `receipt_url` / `matter_slug` spellings are no longer emitted.",
        "properties": {
          "anchor_state": {"description": "Per-proof broadcast state (optional; emitted only when the operator enables state surfacing AND this proof carries a recorded state - absent on pre-feature proofs and dry-run anchors, never null). Honest semantics: `submitted` = handed to the broadcast chain but not yet validated by the network (do NOT treat as anchored); `broadcast` = accepted by the network / in the mempool, 0 confirmations - not yet mined; `confirmed` = mined into a block, 1+ confirmations; `failed` = rejected by the network. A transaction is never claimed mined before `confirmed`.", "enum": ["submitted", "broadcast", "confirmed", "failed"], "type": "string"},
          "bundle_b64": {"description": "Legacy/deprecated, never populated. Retained in the schema for wire-contract stability but always absent/null - the inline-bundle sealed `retain_days=0` mode that returned it no longer exists; use sealed-blind (omit the salt) to keep nothing server-side.", "format": "byte", "type": "string"},
          "bundle_url": {"description": "Bearer-auth `.mbnt` download URL, or null if no server-side bundle exists.", "format": "uri", "type": ["string", "null"]},
          "canonical_b64": {"description": "Sealed-blind mode only. Base64 of the canonical.json bytes; the client assembles the .mbnt locally so the salt never reaches the server.", "format": "byte", "type": "string"},
          "category": {"type": "string"},
          "confirmations": {"description": "Confirmation count recorded with `anchor_state` (optional; present only alongside `anchor_state`): 0 for `broadcast` (network-validated, unmined), 1+ for `confirmed` (mined), null for `submitted`/`failed`.", "type": ["integer", "null"]},
          "doc_hash": {"description": "Sealed-blind mode only. 40-char sha256 prefix stamped into the manifest's `doc_hash_expected`.", "type": "string"},
          "dry_run": {"type": "boolean"},
          "duplicate": {"description": "Present (true) only on dedup-hit replies (a prior anchor for the same sha+folder exists). Look for `note` for the recovery hint.", "type": "boolean"},
          "folder_slug": {"description": "Owning folder slug.", "type": "string"},
          "leaf_count": {"description": "Manifest mode only.", "type": "integer"},
          "lifecycle": {"$ref": "#/components/schemas/LifecycleAdvisory"},
          "mode": {"$ref": "#/components/schemas/AnchorMode"},
          "note": {"description": "Present on dedup-hit replies. Explains the duplicate condition and how to force a fresh anchor.", "type": "string"},
          "proof_id": {"description": "Canonical id of the proof.", "type": "string"},
          "proof_url": {"description": "Canonical proof page URL.", "format": "uri", "type": "string"},
          "retain_until": {"description": "Unix timestamp. Absent for sealed-blind responses (no server-side bundle to retain).", "type": ["integer", "null"]},
          "root": {"description": "Manifest mode only. Hex Merkle root committed on-chain.", "type": "string"},
          "session_id": {"description": "Echo of the request's `session_id`, present only if submitted.", "type": "string"},
          "txid": {"description": "BSV transaction id. Null for dry_run.", "type": ["string", "null"]}
        },
        "required": ["proof_id", "mode", "category", "dry_run", "folder_slug", "proof_url"],
        "type": "object"
      },
      "AnchorScheme": {
        "description": "One supported native disclosure scheme. The booleans are derived live from the server's scheme registries; file_type/granularity/spec_route are presentational metadata.",
        "properties": {
          "scheme": {"description": "The chunk_merkle scheme literal used in the .mbnt carrier (e.g. csv-column-v1).", "type": "string"},
          "file_type": {"description": "Human file kind the scheme applies to.", "enum": ["csv", "text", "json"], "type": "string"},
          "granularity": {"description": "The unit of selective redaction.", "enum": ["row", "column", "line", "keypath", "node", "tree"], "type": "string"},
          "standard": {"description": "Whether the scheme is accepted as a standard (non-sealed) chunk_merkle carrier.", "type": "boolean"},
          "redactable": {"description": "Whether the server can recompute leaves to validate a redacted copy against the anchored root.", "type": "boolean"},
          "sealed_supported": {"description": "Whether the scheme supports the sealed (HMAC) privacy mode.", "type": "boolean"},
          "spec_route": {"description": "Path to the served public specification for this scheme.", "type": "string"}
        },
        "required": ["scheme", "file_type", "granularity", "standard", "redactable", "sealed_supported", "spec_route"],
        "type": "object"
      },
      "AnchorSchemesResponse": {
        "description": "GET /api/v1/anchors/schemes envelope. Public, no auth, CORS-open.",
        "properties": {
          "schemes": {"items": {"$ref": "#/components/schemas/AnchorScheme"}, "type": "array"}
        },
        "required": ["schemes"],
        "type": "object"
      },
      "AnnotationBlock": {
        "description": "Operator annotation on a proof. The chain anchor itself is unaffected.",
        "properties": {
          "annotated_at": {"type": "integer"},
          "is_chain_mutation": {"const": false, "type": "boolean"},
          "kind": {"type": "string"},
          "superseded_by_bundle_id": {"type": "string"},
          "text": {"type": ["string", "null"]}
        },
        "required": ["kind", "annotated_at", "is_chain_mutation"],
        "type": ["object", "null"]
      },
      "AnnotationPatch": {
        "description": "Body of PATCH /api/v1/anchors/{bundle_id}. `annotation: null` clears any prior annotation.",
        "properties": {
          "annotation": {
            "properties": {
              "kind": {"description": "Annotation kind (e.g. `typo`, `superseded`).", "type": "string"},
              "superseded_by_bundle_id": {"description": "When `kind=superseded`, the bundle_id of the replacement anchor.", "type": "string"},
              "text": {"description": "Free-text annotation body.", "type": "string"}
            },
            "type": ["object", "null"]
          }
        },
        "required": ["annotation"],
        "type": "object"
      },
      "ErrorResponse": {
        "description": "Canonical error envelope: `{error: {code, message}}`. The 401 webhook-receive variant uses the same shape with code `signature_mismatch`.",
        "properties": {
          "error": {
            "properties": {
              "code": {"type": "string"},
              "message": {"type": "string"}
            },
            "required": ["code", "message"],
            "type": "object"
          }
        },
        "required": ["error"],
        "type": "object"
      },
      "FolderAnchorListResponse": {
        "description": "GET /api/v1/folders/{slug}/anchors response. `anchor_count` includes soft-deleted proofs because chain anchors are permanent. The top-level container key is `folder` (canonical; the legacy `matter` key is no longer emitted).",
        "properties": {
          "anchor_count": {"type": "integer"},
          "anchors": {"items": {"$ref": "#/components/schemas/AnchorListItem"}, "type": "array"},
          "folder": {"$ref": "#/components/schemas/FolderResource"},
          "note": {"type": "string"},
          "workspace": {"$ref": "#/components/schemas/WorkspaceRef"}
        },
        "required": ["workspace", "folder", "anchor_count", "anchors"],
        "type": "object"
      },
      "FolderCreateRequest": {
        "description": "Body of POST /api/v1/folders (and the legacy /api/v1/matters alias).",
        "properties": {
          "name": {"description": "Human-readable folder label. 2-200 chars.", "maxLength": 200, "minLength": 2, "type": "string"},
          "slug": {"description": "URL-safe folder slug. Minimum 2 chars; harness-string substrings rejected.", "minLength": 2, "type": "string"}
        },
        "required": ["slug", "name"],
        "type": "object"
      },
      "FolderCreateResponse": {
        "description": "Folder-creation response: 201 on a fresh create, or 200 with `duplicate: true` when an identical slug+name re-POST returned the existing folder. Top-level key is `folder` (canonical) on both /api/v1/folders and the legacy /api/v1/matters alias; the legacy `matter` key is no longer emitted.",
        "properties": {
          "duplicate": {"description": "Present and true only on the 200 returned for an identical slug+name re-POST — the existing folder was returned, nothing was created.", "type": "boolean"},
          "folder": {"$ref": "#/components/schemas/FolderResource"}
        },
        "required": ["folder"],
        "type": "object"
      },
      "FolderListResponse": {
        "description": "GET /api/v1/folders (and the legacy /api/v1/matters alias). Top-level key is `folders` (canonical) on both routes; the legacy `matters` key is no longer emitted.",
        "properties": {
          "folders": {"items": {"$ref": "#/components/schemas/FolderResource"}, "type": "array"},
          "workspace": {"$ref": "#/components/schemas/WorkspaceRef"}
        },
        "required": ["workspace", "folders"],
        "type": "object"
      },
      "ApiKey": {
        "description": "View-model for one API key on the /api/v1/keys surface. NEVER carries the raw secret or its hash - the secret appears exactly once, in the mint 201 body's sibling `secret` field. `scopes` display canonical (decision 0046). A folder-scoped key lists its allowlist in `folder_ids`; otherwise `folder_ids` is null (all folders).",
        "properties": {
          "created_at": {
            "type": "integer"
          },
          "expires_at": {
            "description": "Unix seconds; null = no expiry. Honored at auth time (an expired key verifies like a revoked one).",
            "type": [
              "integer",
              "null"
            ]
          },
          "folder_ids": {
            "description": "Folder-id allowlist, or null for all folders in the workspace.",
            "items": {
              "type": "string"
            },
            "type": [
              "array",
              "null"
            ]
          },
          "id": {
            "type": "string"
          },
          "last_used_at": {
            "type": [
              "integer",
              "null"
            ]
          },
          "minted_by_key_id": {
            "description": "Id of the keys:admin key that minted this one (lineage); null for dashboard-minted keys.",
            "type": [
              "string",
              "null"
            ]
          },
          "name": {
            "type": "string"
          },
          "prefix": {
            "description": "First 8 url-safe chars of the key - the lookup handle shown on the dashboard.",
            "pattern": "^[A-Za-z0-9_-]{8}$",
            "type": "string"
          },
          "revoked_at": {
            "type": [
              "integer",
              "null"
            ]
          },
          "scopes": {
            "items": {
              "type": "string"
            },
            "type": "array"
          }
        },
        "required": [
          "id",
          "name",
          "prefix",
          "scopes",
          "folder_ids",
          "expires_at",
          "minted_by_key_id",
          "created_at",
          "last_used_at",
          "revoked_at"
        ],
        "type": "object"
      },
      "ApiKeyListResponse": {
        "description": "200 body from GET /api/v1/keys: the mint lineage of the calling keys:admin key (revoked rows included). Never includes any `secret`.",
        "properties": {
          "keys": {
            "items": {
              "$ref": "#/components/schemas/ApiKey"
            },
            "type": "array"
          }
        },
        "required": [
          "keys"
        ],
        "type": "object"
      },
      "ApiKeyMintRequest": {
        "description": "POST /api/v1/keys body. `name` and `scopes` are required.",
        "properties": {
          "expires_at": {
            "description": "Optional unix seconds; must be in the future and at most 100 years out. Enforced at auth time.",
            "type": "integer"
          },
          "folder_slugs": {
            "description": "Optional. Restrict the sub-key to these folders. Omit for no extra restriction (a folder-scoped minting key still bounds children to its own allowlist). A slug outside the workspace or the minting key's allowlist returns a uniform 404 folder_not_found.",
            "items": {
              "type": "string"
            },
            "minItems": 1,
            "type": "array"
          },
          "name": {
            "description": "Human label shown on the owner dashboard (1-80 chars).",
            "maxLength": 80,
            "minLength": 1,
            "type": "string"
          },
          "scopes": {
            "description": "Required, non-empty. A subset of the minting key's own scopes minus keys:admin. Mintable scopes: anchors:create, proofs:read, proofs:annotate. keys:admin is rejected (403 keys_admin_not_mintable); the dormant `admin` scope is unreachable.",
            "items": {
              "enum": [
                "anchors:create",
                "proofs:read",
                "proofs:annotate"
              ],
              "type": "string"
            },
            "minItems": 1,
            "type": "array"
          }
        },
        "required": [
          "name",
          "scopes"
        ],
        "type": "object"
      },
      "ApiKeyMintResponse": {
        "description": "201 body from POST /api/v1/keys. `key` is the new key's view-model; `secret` is the full `sk_<prefix>_<secret>` credential, returned ONCE and unrecoverable afterwards.",
        "properties": {
          "key": {
            "$ref": "#/components/schemas/ApiKey"
          },
          "secret": {
            "description": "Full bearer credential, shown exactly once.",
            "pattern": "^sk_[A-Za-z0-9_-]{8}_[A-Za-z0-9_-]{32}$",
            "type": "string"
          }
        },
        "required": [
          "key",
          "secret"
        ],
        "type": "object"
      },
      "FolderResource": {
        "description": "One folder row.",
        "properties": {
          "archived": {"type": "boolean"},
          "created_at": {"type": "integer"},
          "id": {"type": "string"},
          "name": {"type": "string"},
          "slug": {"type": "string"}
        },
        "required": ["id", "slug", "name", "archived", "created_at"],
        "type": "object"
      },
      "LifecycleAdvisory": {
        "description": "Advisory-only lifecycle hints attached to fresh anchor-success responses (additive, 2026-06). Explains what the txid means right now, how to check confirmation depth, and why the `.mbnt` bundle should be downloaded immediately. Nothing here is consumed by verifiers; absent on dedup-hit (`duplicate: true`) replies.",
        "properties": {
          "bundle_download": {"description": "Download / retention nudge. Present when a server-side bundle exists (a numeric `retain_until` means the copy is deleted after that timestamp; null means it is kept until the proof is deleted), or as the sealed-blind variant explaining that no server-side copy exists at all.", "type": "string"},
          "confirmation_howto": {"description": "How to check confirmation depth via `status_url`.", "type": "string"},
          "docs_url": {"description": "Public docs section on RECEIVED vs CONFIRMED (explorer indexing lag).", "format": "uri", "type": "string"},
          "retention_docs_url": {"description": "Public docs section on durability/retention across modes. Present alongside the server-bundle `bundle_download` variant.", "format": "uri", "type": "string"},
          "status_url": {"description": "WhatsOnChain tx-status URL for this txid: 404 until the tx is indexed, then the JSON includes a `confirmations` count. Absent when the response has no txid (dry-run).", "format": "uri", "type": "string"},
          "tx_status": {"description": "What the txid means at response time (fresh anchors are typically unconfirmed; explorers can lag).", "type": "string"}
        },
        "required": ["tx_status", "confirmation_howto", "docs_url"],
        "type": "object"
      },
      "LookupHashHit": {
        "description": "Hit response - the sha resolves to a known file-hash sidecar. Emits the canonical `proof_id` key.",
        "properties": {
          "created_utc": {"type": "string"},
          "proof_id": {"type": "string"},
          "txid": {"type": ["string", "null"]}
        },
        "required": ["proof_id", "txid"],
        "type": "object"
      },
      "LookupHashMiss": {
        "description": "Miss response - the sha is not indexed as a file hash (could be a merkle root, sealed commitment, commit-doc sha, or genuinely unknown).",
        "properties": {
          "miss": {"const": true, "type": "boolean"},
          "reason": {"enum": ["sha_not_indexed_as_file_hash"], "type": "string"}
        },
        "required": ["miss", "reason"],
        "type": "object"
      },
      "ManifestBlock": {
        "description": "Manifest summary on a proof detail response (manifest mode only).",
        "properties": {
          "leaf_count": {"type": "integer"},
          "root": {"type": "string"},
          "scheme": {"type": "string"}
        },
        "type": "object"
      },
      "ManifestItem": {
        "description": "One leaf in a manifest anchor.",
        "properties": {
          "label": {"type": "string"},
          "sha256_hex": {"pattern": "^[0-9a-f]{64}$", "type": "string"}
        },
        "required": ["sha256_hex"],
        "type": "object"
      },
      "ProofLeavesSealed": {
        "description": "Off-chain leaf companion for sealed `chunk_merkle` proofs. Rides in the bundle's proofs.json; does NOT enter the canonical doc.",
        "properties": {
          "merkle_leaves": {"items": {"pattern": "^[0-9a-f]{64}$", "type": "string"}, "type": "array"},
          "metadata": {"properties": {"leaf_count": {"type": "integer"}}, "type": "object"},
          "scheme": {"type": "string"}
        },
        "type": "object"
      },
      "ProofLeavesStandard": {
        "description": "Off-chain leaf companion for standard-mode `chunk_merkle` proofs. Rides in the bundle's proofs.json; does NOT enter the canonical doc.",
        "properties": {
          "merkle_leaves": {"items": {"pattern": "^[0-9a-f]{64}$", "type": "string"}, "type": "array"},
          "metadata": {"properties": {"leaf_count": {"type": "integer"}}, "type": "object"},
          "scheme": {"type": "string"}
        },
        "type": "object"
      },
      "ProofSetSealed": {
        "description": "v2 multi-proof envelope (sealed mode). HMAC algos throughout; `byte_exact.commitment` mirrors the top-level `byte_exact_commitment`.",
        "properties": {
          "byte_exact": {
            "properties": {
              "algo": {"enum": ["hmac-sha256"], "type": "string"},
              "commitment": {"pattern": "^[0-9a-f]{64}$", "type": "string"}
            },
            "required": ["algo", "commitment"],
            "type": "object"
          },
          "chunk_merkle": {
            "properties": {
              "algo": {"enum": ["merkle-hmac-sha256"], "type": "string"},
              "leaf_count": {"type": "integer"},
              "root": {"pattern": "^[0-9a-f]{64}$", "type": "string"},
              "scheme": {"type": "string"}
            },
            "type": "object"
          },
          "content_canonical": {
            "properties": {
              "algo": {"enum": ["hmac-sha256"], "type": "string"},
              "commitment": {"pattern": "^[0-9a-f]{64}$", "type": "string"},
              "scheme": {"type": "string"}
            },
            "type": "object"
          },
          "session_commitment": {
            "description": "Phase 4.2 additive - sealed session commitment proof type.",
            "type": "object"
          }
        },
        "required": ["byte_exact"],
        "type": "object"
      },
      "ProofSetStandard": {
        "description": "v2 multi-proof envelope (standard mode). `byte_exact` is required and must match top-level sha256_hex + file_size.",
        "properties": {
          "byte_exact": {
            "properties": {
              "algo": {"enum": ["sha256"], "type": "string"},
              "hash": {"pattern": "^[0-9a-f]{64}$", "type": "string"},
              "size": {"minimum": 0, "type": "integer"}
            },
            "required": ["hash"],
            "type": "object"
          },
          "chunk_merkle": {
            "properties": {
              "algo": {"enum": ["sha256"], "type": "string"},
              "leaf_count": {"type": "integer"},
              "root": {"pattern": "^[0-9a-f]{64}$", "type": "string"},
              "scheme": {"type": "string"}
            },
            "type": "object"
          },
          "content_canonical": {
            "properties": {
              "algo": {"enum": ["sha256"], "type": "string"},
              "hash": {"pattern": "^[0-9a-f]{64}$", "type": "string"},
              "scheme": {"type": "string"}
            },
            "type": "object"
          }
        },
        "required": ["byte_exact"],
        "type": "object"
      },
      "ProvenanceAnchorRequest": {
        "description": "Body of POST /api/v1/provenance/anchor. Two mutually-exclusive shapes selected by the fields present (NOT a `mode` discriminator):\n\n  - **ProvenanceAnchorPlaintext**: a `manifest` object is present → the server canonicalizes it and commits its sha256.\n  - **ProvenanceAnchorSealed**: `byte_exact_commitment` (and/or `salt_b64` / `proof_set` / `proof_leaves`) present WITHOUT `manifest` → the client HMAC-blinds the manifest locally and the server never holds it.\n\nThe two shapes share no required field, so this is a oneOf rather than an OpenAPI `discriminator`. Sending a plaintext `manifest` whose `privacy.onchain_mode` is `sealed` is rejected 400 `sealed_manifest_on_plaintext_route`.",
        "oneOf": [
          {"$ref": "#/components/schemas/ProvenanceAnchorPlaintext"},
          {"$ref": "#/components/schemas/ProvenanceAnchorSealed"}
        ]
      },
      "ProvenanceAnchorPlaintext": {
        "description": "Plaintext provenance request: the full manifest is sent. It is strict-normalized + SCJ-v1-canonicalized server-side (Satsignal Canonical JSON — deliberately not RFC 8785/JCS; see /spec-provenance §3); its sha256 is the dedup key + on-chain commitment.",
        "properties": {
          "category": {"type": "string"},
          "folder_slug": {"description": "Folder slug. Required - send `folder_slug` (canonical) or its legacy alias `matter_slug`.", "type": "string"},
          "force_new": {"description": "Opt out of default manifest-sha dedup (anchor again even if this manifest was anchored before; counts against quota).", "type": "boolean"},
          "label": {"type": "string"},
          "manifest": {"description": "satsignal.provenance.v1 manifest object. Must NOT declare `privacy.onchain_mode:\"sealed\"` — a sealed manifest on this route is rejected 400 `sealed_manifest_on_plaintext_route`; use the sealed shape instead.", "type": "object"},
          "matter_slug": {"deprecated": true, "description": "Legacy alias of `folder_slug`, still accepted.", "type": "string"},
          "session_id": {"type": "string"}
        },
        "required": ["manifest"],
        "type": "object"
      },
      "ProvenanceAnchorSealed": {
        "description": "Sealed provenance request: the client locally HMAC-blinds the manifest and submits ONLY the commitment block — the plaintext manifest never reaches the server (so it can never be sha256'd + stored). There is no `manifest` field, no server-side normalize/canonicalize, and no manifest_sha dedup. `byte_exact_commitment` is `HMAC-SHA256(master_salt, SCJ-v1(manifest))`; see /spec-provenance §6 for the commitment construction + manifest canonicalization (SCJ-v1, Satsignal Canonical JSON — deliberately not RFC 8785/JCS, see /spec-provenance §3: UTF-8 NFC / code-point-sorted keys / `,`+`:` separators / floats forbidden). Either `matter_slug` or `folder_slug` is required. Salt-bearing vs blind mirrors AnchorRequestSealed: omit `salt_b64` (and keep `retain_days` 0 / absent) for blind sealed — the response then carries `canonical_b64` + `doc_hash` for client-side bundle assembly instead of a server-side mirror.",
        "properties": {
          "byte_exact_commitment": {"description": "Lowercase 64-char hex HMAC-SHA256(master_salt, SCJ-v1(manifest)) commitment (SCJ-v1, not RFC 8785/JCS; see /spec-provenance §3). Required.", "pattern": "^[0-9a-f]{64}$", "type": "string"},
          "category": {"type": "string"},
          "file_size": {"description": "Original file size in bytes. Optional; does NOT enter the on-chain envelope. Must be a JSON integer.", "minimum": 0, "type": "integer"},
          "folder_slug": {"description": "Folder slug. Required - send `folder_slug` (canonical) or its legacy alias `matter_slug`.", "type": "string"},
          "label": {"type": "string"},
          "matter_slug": {"deprecated": true, "description": "Legacy alias of `folder_slug`, still accepted.", "type": "string"},
          "proof_leaves": {"$ref": "#/components/schemas/ProofLeavesSealed"},
          "proof_set": {"$ref": "#/components/schemas/ProofSetSealed"},
          "retain_days": {"description": "Optional explicit auto-delete window (days) for the salt-bearing mirror bundle. OMIT it (the default) for indefinite retention — the bundle is kept until the proof is deleted and the response carries `retain_until: null`. An explicit value >= 1 is honored as given on every plan. retain_days=0 is valid ONLY in blind mode (omit salt_b64): a salt-bearing retain_days=0 is rejected with 400 `retain_days: 0 requires blind mode — omit salt_b64` (never clamped). Must be a JSON integer.", "minimum": 0, "type": "integer"},
          "salt_b64": {"description": "Base64url (unpadded) of the 32-byte master salt. Absent (or empty) selects blind sealed mode (no salt reaches the server). When omitted, `retain_days` must be 0 / absent; when present, `retain_days` must be >= 1.", "type": "string"},
          "session_id": {"type": "string"},
          "sha256_hex": {"description": "Optional lowercase 64-char hex sha256 of the original file bytes (sidecar metadata; NOT the sealed commitment).", "pattern": "^[0-9a-f]{64}$", "type": "string"}
        },
        "required": ["byte_exact_commitment"],
        "type": "object"
      },
      "ProvenanceAnchorResponse": {
        "description": "POST /api/v1/provenance/anchor response. One of two shapes keyed on `mode`: `provenance` (plaintext) carries `manifest_hash` + a chain_anchor envelope; `provenance_sealed` carries NO `manifest_hash` (the server never held the manifest) and, for blind sealed, `canonical_b64` + `doc_hash` for client-side bundle assembly.",
        "oneOf": [
          {"$ref": "#/components/schemas/ProvenanceAnchorResponsePlaintext"},
          {"$ref": "#/components/schemas/ProvenanceAnchorResponseSealed"}
        ]
      },
      "ProvenanceAnchorResponsePlaintext": {
        "description": "Plaintext provenance response (`mode: provenance`). Includes the manifest hash + a ready-to-embed chain-anchor-v1 envelope.",
        "properties": {
          "anchor_state": {"description": "Per-proof broadcast state (optional, feature-flagged; same semantics as AnchorResponse.anchor_state - absent on pre-feature proofs and dry-run anchors). `broadcast` = accepted by the network / mempool, 0 confirmations; `confirmed` = mined, 1+ confirmations.", "enum": ["submitted", "broadcast", "confirmed", "failed"], "type": "string"},
          "bundle_url": {"format": "uri", "type": ["string", "null"]},
          "category": {"type": "string"},
          "chain_anchor": {"description": "Ready-to-embed chain-anchor-v1 envelope an adapter can drop into a downstream proof.", "type": "object"},
          "confirmations": {"description": "Confirmation count recorded with `anchor_state` (optional; present only alongside it): 0 for `broadcast`, 1+ for `confirmed`, null otherwise.", "type": ["integer", "null"]},
          "dry_run": {"type": "boolean"},
          "duplicate": {"type": "boolean"},
          "folder_slug": {"description": "Owning folder slug.", "type": "string"},
          "lifecycle": {"$ref": "#/components/schemas/LifecycleAdvisory"},
          "manifest_hash": {"description": "sha256 of the SCJ-v1-canonicalized manifest bytes (SCJ-v1, not RFC 8785/JCS; see /spec-provenance §3).", "type": "string"},
          "mode": {"const": "provenance", "type": "string"},
          "note": {"type": "string"},
          "proof_id": {"description": "Canonical id of the proof.", "type": "string"},
          "proof_url": {"description": "Canonical proof page URL.", "format": "uri", "type": "string"},
          "retain_until": {"type": ["integer", "null"]},
          "session_id": {"type": "string"},
          "txid": {"type": ["string", "null"]}
        },
        "required": ["proof_id", "mode", "manifest_hash", "folder_slug", "proof_url"],
        "type": "object"
      },
      "ProvenanceAnchorResponseSealed": {
        "description": "Sealed provenance response (`mode: provenance_sealed`). Carries NO `manifest_hash` — the server never held the manifest. For blind sealed (no salt sent) the body carries `canonical_b64` + `doc_hash` so the client assembles the .mbnt locally and `retain_until` is absent.",
        "properties": {
          "anchor_state": {"description": "Per-proof broadcast state (optional, feature-flagged; same semantics as AnchorResponse.anchor_state - absent on pre-feature proofs and dry-run anchors). `broadcast` = accepted by the network / mempool, 0 confirmations; `confirmed` = mined, 1+ confirmations.", "enum": ["submitted", "broadcast", "confirmed", "failed"], "type": "string"},
          "bundle_url": {"description": "Bearer-auth `.mbnt` download URL, or null when no server-side bundle exists (blind).", "format": "uri", "type": ["string", "null"]},
          "bundle_b64": {"description": "Legacy/deprecated, never populated. Retained in the schema for wire-contract stability but always absent/null - the inline-bundle sealed salt-bearing `retain_days=0` mode that returned it no longer exists; use sealed-blind (omit the salt) to keep nothing server-side.", "format": "byte", "type": "string"},
          "canonical_b64": {"description": "Sealed-blind only. Base64 of the canonical.json bytes; the client assembles the .mbnt locally so the salt never reaches the server.", "format": "byte", "type": "string"},
          "category": {"type": "string"},
          "confirmations": {"description": "Confirmation count recorded with `anchor_state` (optional; present only alongside it): 0 for `broadcast`, 1+ for `confirmed`, null otherwise.", "type": ["integer", "null"]},
          "doc_hash": {"description": "Sealed-blind only. 40-char sha256 prefix stamped into the manifest's `doc_hash_expected`.", "type": "string"},
          "dry_run": {"type": "boolean"},
          "folder_slug": {"description": "Owning folder slug.", "type": "string"},
          "lifecycle": {"$ref": "#/components/schemas/LifecycleAdvisory"},
          "mode": {"const": "provenance_sealed", "type": "string"},
          "proof_id": {"description": "Canonical id of the proof.", "type": "string"},
          "proof_url": {"description": "Canonical proof page URL.", "format": "uri", "type": "string"},
          "retain_until": {"description": "Unix timestamp when an explicit retain_days window was chosen; null for a sealed mirror retained indefinitely (the default). Absent for sealed-blind responses (no server-side bundle to retain).", "type": ["integer", "null"]},
          "session_id": {"type": "string"},
          "txid": {"description": "BSV transaction id; null for dry-run.", "type": ["string", "null"]}
        },
        "required": ["proof_id", "mode", "folder_slug", "proof_url"],
        "type": "object"
      },
      "QuotaExceededResponse": {
        "description": "429 body for quota exhaustion.",
        "properties": {
          "error": {
            "properties": {
              "code": {"enum": ["quota_exceeded"], "type": "string"},
              "message": {"type": "string"}
            },
            "type": "object"
          },
          "limit": {"type": "integer"},
          "plan": {"type": "string"},
          "used_today": {"type": "integer"}
        },
        "required": ["error", "plan", "used_today", "limit"],
        "type": "object"
      },
      "RateLimitedResponse": {
        "description": "429 body for the webhook-receive per-id rate limit (60 deliveries / 60s per webhook id).",
        "properties": {
          "error": {
            "properties": {
              "code": {"enum": ["rate_limited"], "type": "string"},
              "message": {"type": "string"}
            },
            "type": "object"
          },
          "retry_after": {"type": "integer"}
        },
        "required": ["error", "retry_after"],
        "type": "object"
      },
      "UsageResponse": {
        "description": "GET /api/v1/usage envelope.",
        "properties": {
          "anchors_allowed": {"description": "Whether a new anchor would currently be admitted (false when quota is exhausted or the trial expired into read-only).", "type": "boolean"},
          "limit": {"type": "integer"},
          "period_end_utc": {"description": "Unix timestamp at which the quota window resets. Same value as `X-RateLimit-Reset`.", "type": "integer"},
          "plan": {"type": "string"},
          "reason": {"description": "When `anchors_allowed` is false, the human-readable message the 429 would surface.", "type": ["string", "null"]},
          "remaining": {"type": "integer"},
          "used": {"type": "integer"},
          "window": {"enum": ["day", "month"], "type": "string"},
          "workspace": {"$ref": "#/components/schemas/WorkspaceRef"}
        },
        "required": ["workspace", "plan", "window", "used", "limit", "remaining", "period_end_utc", "anchors_allowed"],
        "type": "object"
      },
      "WebhookCreateRequest": {
        "description": "Body of POST /api/v1/webhooks.",
        "properties": {
          "folder_slug": {"description": "Target folder slug. Required - send `folder_slug` (canonical) or its legacy alias `matter_slug`.", "type": "string"},
          "label": {"description": "Optional human-readable label.", "maxLength": 256, "type": "string"},
          "matter_slug": {"deprecated": true, "description": "Legacy alias of `folder_slug`, still accepted.", "type": "string"},
          "signing_secret": {"description": "Optional caller-supplied signing secret. For `source_type=none` and `source_type=github`, the server generates one if absent and returns it ONCE in `secret`. For `source_type=stripe` and `source_type=langfuse`, the source generates its own secret - supply it here at create or via PATCH.", "type": "string"},
          "source_type": {"enum": ["stripe", "github", "langfuse", "none"], "type": "string"}
        },
        "required": ["folder_slug", "source_type"],
        "type": "object"
      },
      "WebhookCreateResponse": {
        "allOf": [
          {"$ref": "#/components/schemas/WebhookResource"},
          {
            "properties": {
              "note": {"description": "Optional source-specific setup hint (e.g. Stripe / Langfuse paste-and-PATCH walkthrough, GitHub config instructions).", "type": "string"},
              "secret": {"description": "ONE-TIME signing secret (`source_type=none` and `source_type=github` only). Unrecoverable - store it now.", "type": "string"},
              "secret_note": {"description": "Hint that the `secret` is one-shot.", "type": "string"}
            },
            "type": "object"
          }
        ],
        "description": "201 response from webhook creation. Extends WebhookResource with the one-shot `secret` field (present only for `source_type=none` and `source_type=github` configurations)."
      },
      "WebhookListResponse": {
        "description": "GET /api/v1/webhooks response. Paginated by `created_at` DESC.",
        "properties": {
          "before": {"type": ["integer", "null"]},
          "limit": {"type": "integer"},
          "next_cursor": {"description": "Pass to `before=` for the next page; null on the final page.", "type": ["integer", "null"]},
          "webhook_count": {"type": "integer"},
          "webhooks": {"items": {"$ref": "#/components/schemas/WebhookResource"}, "type": "array"},
          "workspace_total": {"type": "integer"}
        },
        "required": ["webhook_count", "workspace_total", "limit", "next_cursor", "webhooks"],
        "type": "object"
      },
      "WebhookPatchRequest": {
        "description": "Body of PATCH /api/v1/webhooks/{webhook_id}. At least one field is required.",
        "properties": {
          "label": {"description": "New label. Pass `null` to clear.", "type": ["string", "null"]},
          "revoked": {"description": "`true` revokes the config. `false` is rejected (`invalid_value`) - un-revoke is not exposed; mint a new config to rotate.", "enum": [true], "type": "boolean"},
          "signing_secret": {"description": "New signing secret. Returns 410 `webhook_revoked` if the config was revoked between get and update.", "type": "string"}
        },
        "type": "object"
      },
      "WebhookReceiveResponse": {
        "description": "200 response from POST /api/v1/webhooks/{webhook_id} (receive). `duplicate: true` indicates a same-body Stripe-style retry hit dedup.",
        "properties": {
          "anchor_state": {"description": "Per-proof broadcast state (optional, feature-flagged; same semantics as AnchorResponse.anchor_state - absent on pre-feature proofs and dry-run anchors). `broadcast` = accepted by the network / mempool, 0 confirmations; `confirmed` = mined, 1+ confirmations.", "enum": ["submitted", "broadcast", "confirmed", "failed"], "type": "string"},
          "bundle_url": {"format": "uri", "type": ["string", "null"]},
          "confirmations": {"description": "Confirmation count recorded with `anchor_state` (optional; present only alongside it): 0 for `broadcast`, 1+ for `confirmed`, null otherwise.", "type": ["integer", "null"]},
          "duplicate": {"type": "boolean"},
          "folder_slug": {"description": "Owning folder slug.", "type": "string"},
          "mode": {"$ref": "#/components/schemas/AnchorMode"},
          "note": {"type": "string"},
          "proof_id": {"description": "Canonical id of the proof.", "type": "string"},
          "proof_url": {"description": "Canonical proof page URL.", "format": "uri", "type": "string"},
          "txid": {"type": ["string", "null"]}
        },
        "required": ["duplicate", "proof_id", "mode", "folder_slug", "proof_url"],
        "type": "object"
      },
      "WebhookResource": {
        "description": "View-model for a webhook config. NEVER includes `signing_secret` - one-shot at create time only.",
        "properties": {
          "created_at": {"type": "integer"},
          "folder_slug": {"description": "Slug of the folder this config anchors into.", "type": ["string", "null"]},
          "label": {"type": ["string", "null"]},
          "last_used_at": {"type": ["integer", "null"]},
          "revoked_at": {"type": ["integer", "null"]},
          "secret_set": {"description": "Whether a signing secret has been provisioned. Until true, deliveries cannot verify.", "type": "boolean"},
          "source_type": {"enum": ["stripe", "github", "langfuse", "none"], "type": "string"},
          "url": {"description": "Absolute URL of the receive endpoint - paste this into the source's webhook config.", "format": "uri", "type": "string"},
          "webhook_id": {"pattern": "^wh_[A-Za-z0-9_-]+$", "type": "string"}
        },
        "required": ["webhook_id", "url", "source_type", "secret_set", "created_at"],
        "type": "object"
      },
      "WorkspaceRef": {
        "description": "Workspace identifier triple returned alongside many list/usage responses.",
        "properties": {
          "id": {"type": "string"},
          "name": {"type": "string"},
          "slug": {"type": "string"}
        },
        "required": ["id", "slug", "name"],
        "type": "object"
      }
    },
    "securitySchemes": {
      "BearerAuth": {
        "bearerFormat": "sk_<prefix>_<secret>",
        "description": "Customer API key. Mint at /w/{workspace}/keys/. Bearer token format: `sk_<8-char prefix>_<32-char secret>` (~190 bits of entropy in the secret half). Canonical scopes are `proofs:read` / `proofs:annotate`; the legacy `receipts:read` / `receipts:annotate` spellings remain accepted and authorize the same operations (keys minted with either spelling work everywhere). New keys mint the canonical spelling; the default-mint scope set is `[anchors:create, proofs:read]`; `proofs:annotate` and `admin` must be added explicitly when minting.",
        "scheme": "bearer",
        "type": "http"
      },
      "WebhookSignatureAuth": {
        "description": "HMAC signature of the raw request body in `X-Satsignal-Signature`. For `source_type=none`, signed as HMAC-SHA256(secret, '<unix-timestamp>.' + body). For configured source types (stripe, github, langfuse), verifies the source's native signature scheme (Stripe-Signature, X-Hub-Signature-256, etc.). ALL failure modes (unknown id, revoked config, missing signature, stale timestamp, mismatch, unprovisioned secret) collapse to the same 401 `signature_mismatch` response for enumeration defense.",
        "in": "header",
        "name": "X-Satsignal-Signature",
        "type": "apiKey"
      }
    }
  }
}
