# The Agent Door HTTP surface, as an OpenAPI 3.1 document (#49 item 4).
#
# WHY THIS FILE EXISTS: before it, the contract outside @bubblio/server lived only in code
# comments and dashboard docs pages — an integrator not using the SDK had nothing typed to
# build against. This document is DERIVED FROM THE ROUTES, not aspirational:
#   apps/bubblio-api/src/routes/agent-door.ts        (public door + hub + results/paid push)
#   apps/bubblio-api/src/routes/agent-keys.ts        (managed keys + connect grant)
#   apps/bubblio-api/src/routes/approvals.ts         (the consent pages' API)
#   apps/bubblio-api/src/routes/agent-door-admin.ts  (the actions sync endpoint)
#   packages/server/src/index.ts                     (the callback contract the SDK verifies)
# When code and this file disagree, code wins — fix the file in the same change.
#
# Deliberately OUT of scope: the dashboard-JWT-only door admin CRUD (policy flips live in the
# dashboard, never in an API contract an automated caller could hold), the widget/session
# surface, and everything behind /v1 that is not part of the door. Repo docs on purpose — the
# spec ships with the code that implements it and is not (yet) served over HTTP.
#
# Two probing-resistance properties shape many responses below, and are worth reading first:
#   · UNIFORM MISSES — unknown, malformed, foreign, disabled, and migration-lagged resources
#     all answer the same 404 body per surface ("No agent door here." / "No approval here." /
#     the push routes' combined unknown-or-unsigned message), so a prober cannot tell closed
#     from nonexistent. Signature failures on the push endpoints answer that same 404, not 401.
#   · CALM ERRORS — refusals are one plain sentence, machine codes ride a `code` field, and
#     processor/decline internals never cross this wire.

openapi: 3.1.0
info:
  title: Bubblio Agent Door API
  version: 0.13.0 # tracks the @bubblio/server release this contract shipped with
  description: |
    The machine entrance to Bubblio-powered platforms: discovery (directory, manifests,
    llms.txt), the per-door and hub MCP endpoints (quote → confirm actions, grounded asks),
    the consent-page API humans approve on, managed agent keys, the connect-grant flow, and
    the two signed platform push endpoints (deferred results, payment-link settlement).

    Authentication comes in four distinct flavors, used exactly where each is safe:
      1. **None** — the public door surface (`/agent/**`). Agents call with no credential;
         rate ceilings are published in each manifest.
      2. **Platform API key** (`Authorization: Bearer bbl_…`) — the platform's own server
         calling `/v1/**`. Never valid from a browser.
      3. **Signed push** — API key **plus** the platform's webhook-signing secret over
         `X-Bubblio-Signature-V2`/`X-Bubblio-Timestamp` (HMAC-SHA256 of
         `${timestamp}.${rawBody}`, ±5 min window). The key alone can never move a receipt.
      4. **Consent token** (`Authorization: Bearer <JWT>` from `POST /approvals/session`) —
         the human's passkey session on the approvals management surface.
  contact:
    email: bubblio@andresio.com

servers:
  - url: https://andresomar95--bubblio-api-serve.modal.run
    description: Hosted production platform (the SDK's default BUBBLIO_API_URL).

tags:
  - name: discovery
    description: Credential-free discovery — directory, manifests, llms.txt signposts.
  - name: mcp
    description: |
      The MCP endpoints. Minimal, hand-rolled, STATELESS Streamable-HTTP servers: POST one
      JSON-RPC 2.0 message per request, receive one `application/json` response (legal per
      the MCP spec — no SSE, no session ids). Tool failures come back as `isError` RESULTS
      the calling model can read; JSON-RPC `error` objects are reserved for malformed
      requests, unknown methods, and unknown tools. Supported methods: `initialize`,
      `ping`, `tools/list`, `tools/call`; notifications (no `id`) are acknowledged 202.
  - name: approvals
    description: The phone-first consent pages' API (bubblio.dev/a/{id} and /approvals).
  - name: agent-keys
    description: Managed agent keys — minted by the platform's server for its signed-in users.
  - name: platform-push
    description: Signed platform → Bubblio pushes (late results, payment-link settlements).
  - name: platform-sync
    description: The code-declared action registry sync (shapes only, never policy).

paths:
  # ─── Discovery ──────────────────────────────────────────────────────────────
  /agent/directory:
    get:
      tags: [discovery]
      summary: Every open door, plus the hub block
      description: |
        Lists doors whose owners opened them (approved accounts with the door enabled)
        AND chose to be listed — an owner may run an unlisted door that works for anyone
        holding its doorId/MCP URL but appears nowhere here (and does not rank in the
        hub's `find`, nor resolve by handle). Absence from this list therefore never
        proves a door does not exist.
        Paid actions ride each entry so agent builders can price-shop without knocking.
        A transient read failure degrades to an honest empty `doors` list — never a 500;
        the `hub` block is served regardless.
      operationId: getDirectory
      responses:
        '200':
          description: The directory.
          content:
            application/json:
              schema:
                type: object
                required: [hub, feed, doors]
                properties:
                  hub:
                    type: object
                    description: Where the one-connector hub lives (mcp/llms/manifest URLs).
                  feed:
                    type: object
                    description: |
                      Where the ACP-compatible feed lives (url + note) — the same listed
                      doors as product-feed rows, for crawlers without an MCP client.
                  doors:
                    type: array
                    items:
                      type: object
                      required: [doorId, label, handle, manifest, mcp]
                      properties:
                        doorId: { $ref: '#/components/schemas/DoorId' }
                        label: { type: string }
                        handle:
                          type: string
                          description: |
                            How the hub addresses this door by argument. DERIVED from the
                            label (never stored) — an owner's label edit renames it; the
                            doorId is the name that never moves.
                        blurb: { type: string }
                        manifest: { type: string, format: uri }
                        mcp: { type: string, format: uri }
                        paid_actions:
                          type: array
                          description: Present only when the door has priced actions.
                          items:
                            type: object
                            required: [name, price]
                            properties:
                              name: { type: string }
                              price: { type: string, examples: ['$0.40'] }

  /agent/feed.json:
    get:
      tags: [discovery]
      summary: The ACP-compatible feed of listed doors and their visible actions
      description: |
        The directory reshaped for agents WITHOUT an MCP client (ChatGPT/Gemini-class
        crawlers and feed ingesters): one row per listed door's visible action — an
        ask-only door gets one door row — carrying the Agentic Commerce Protocol
        product-feed REQUIRED fields (item_id, title, description, brand, url, price,
        availability, is_eligible_search, is_eligible_checkout, seller_name, seller_url)
        plus `bubblio_*` extension fields pointing at the agent-native MCP path.

        These are service actions, not retail SKUs, and the divergences are DECLARED in
        the body's `mapping` notes rather than faked: no image_url; availability is
        always `in_stock` (a listed action is live — services carry no inventory); free
        actions and door rows price at `0.00 USD`; `is_eligible_checkout` is false on
        every row because ACP checkout endpoints are not implemented — transacting is
        MCP quote→confirm at `bubblio_mcp_url`, where the quote pins the exact price.

        Exclusion matches the directory verbatim: unapproved, disabled, and unlisted
        doors never appear, hidden (off/orphaned) actions never appear, and no row
        carries an identifier that isn't already public in GET /agent/directory.

        Cache-friendly by design: the body is rebuilt at most every `ttl_seconds`
        (`generated_at` says when; `Cache-Control`/`Age` say so on the wire). A
        transient read failure serves an honest empty `items` list — never a 500,
        never cached. Per-caller rate-capped; tripping it is a calm 429 with
        Retry-After.
      operationId: getAgentFeed
      responses:
        '200':
          description: The feed.
          content:
            application/json:
              schema:
                type: object
                required: [kind, version, generated_at, ttl_seconds, hub, directory, mapping, items]
                properties:
                  kind: { type: string, const: bubblio-agent-feed }
                  version: { type: string }
                  generated_at: { type: string, format: date-time }
                  ttl_seconds: { type: integer }
                  hub:
                    type: object
                    description: The one-connector hub block (the headline path stays MCP).
                  directory: { type: string, format: uri }
                  docs: { type: string, format: uri }
                  mapping:
                    type: array
                    description: The honest retail-vocabulary divergences, stated in-band.
                    items: { type: string }
                  items:
                    type: array
                    items:
                      type: object
                      required:
                        [item_id, title, description, brand, url, price, availability,
                         is_eligible_search, is_eligible_checkout, seller_name, seller_url]
                      properties:
                        item_id:
                          type: string
                          description: '`<doorId>:<action>` for action rows; the doorId alone for door rows.'
                        title: { type: string }
                        description: { type: string }
                        brand: { type: string }
                        url: { type: string, format: uri }
                        price: { type: string, examples: ['0.40 USD'] }
                        availability: { type: string, const: in_stock }
                        is_eligible_search: { type: boolean, const: true }
                        is_eligible_checkout: { type: boolean, const: false }
                        seller_name: { type: string }
                        seller_url: { type: string, format: uri }
                        bubblio_door_id: { $ref: '#/components/schemas/DoorId' }
                        bubblio_handle: { type: string }
                        bubblio_mcp_url: { type: string, format: uri }
                        bubblio_llms_url: { type: string, format: uri }
                        bubblio_connect_available:
                          type: boolean
                          description: Whether the door's phone-login connect lane is open (a boolean, never the URL).
                        bubblio_blurb: { type: string }
                        bubblio_action:
                          type: string
                          description: Present on action rows only.
                        bubblio_action_policy:
                          type: string
                          enum: [keyed, any]
        '429':
          description: Per-caller cap tripped — calm error body, clamped Retry-After header.

  /agent/hub:
    get:
      tags: [discovery]
      summary: The hub manifest
      description: |
        One claude.ai connector for the whole directory. Stable by construction — no DB
        read; the six-tool surface never changes as doors come and go.
      operationId: getHubManifest
      responses:
        '200':
          description: The hub manifest.
          content:
            application/json:
              schema:
                type: object
                required: [kind, version, label, capabilities, endpoints, tools, limits, notice]
                properties:
                  kind: { type: string, const: bubblio-agent-hub }
                  version: { type: string }
                  label: { type: string }
                  capabilities:
                    type: array
                    items: { type: string, enum: [find, ask, about, act] }
                  endpoints:
                    type: object
                    properties:
                      mcp: { type: string, format: uri }
                      llms: { type: string, format: uri }
                      directory: { type: string, format: uri }
                      feed: { type: string, format: uri }
                  tools:
                    type: array
                    items: { type: string, enum: [about, ask, list_actions, quote, confirm, find] }
                  limits:
                    type: object
                    description: find's own published ceilings; the other five tools spend the resolved door's budgets.
                  notice: { type: string }
                  docs: { type: string, format: uri }

  /agent/hub/llms.txt:
    get:
      tags: [discovery]
      summary: The hub's plain-text signpost
      operationId: getHubLlmsTxt
      responses:
        '200':
          description: Plain text for LLM crawlers — the one-connector path, tools, and a bare-POST example.
          content:
            text/plain:
              schema: { type: string }

  /agent/hub/mcp:
    get:
      tags: [mcp]
      summary: 'Stateless-JSON truth: nothing to GET'
      operationId: getHubMcp
      responses:
        '405':
          description: This MCP endpoint is stateless JSON — POST your JSON-RPC messages.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CalmError' }
    post:
      tags: [mcp]
      summary: The hub MCP endpoint (JSON-RPC 2.0, one message per request)
      description: |
        Same envelope as every per-door endpoint (64KB body cap, notifications → 202,
        protocol errors as JSON-RPC `error` on HTTP 200). Tool routing differs in exactly
        two ways: a sixth tool `find { query }` searches the live directory, and the five
        door tools take a `door` argument (handle or door id) resolved through the
        UNCHANGED per-door machinery — custody, approvals, mandates, payments, and rate
        buckets all scope to the resolved door, exactly as if the agent had knocked on the
        door's own URL. The hub is routing, never a bypass, and holds no credential.
      operationId: postHubMcp
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/JsonRpcRequest' }
      responses:
        '200':
          description: JSON-RPC result — or a JSON-RPC error object for malformed/unknown requests.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/JsonRpcResponse' }
        '202': { description: 'Notification (no `id`): acknowledged, no body.' }
        '413':
          description: Body over 64KB (JSON-RPC error envelope).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/JsonRpcResponse' }
        '429':
          description: |
            A published ceiling tripped (JSON-RPC error envelope, code -32000; HTTP 429 so
            generic clients back off too). `Retry-After` is advisory and clamped to 3600s;
            the MESSAGE carries the real limit to pace against.
          headers:
            Retry-After: { schema: { type: integer } }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/JsonRpcResponse' }

  /agent/{doorId}:
    get:
      tags: [discovery]
      summary: A door's manifest
      description: |
        What this door is and what an agent may do here. `act` appears in `capabilities`
        only while ≥1 action is visible. EVERY rate ceiling is published so an agent can
        pace itself instead of discovering limits as 429s. Priced actions ride `pricing`
        with the payment profile (accepted rails + the platform's Stripe PUBLISHABLE key —
        facts only, never a credential).
      operationId: getDoorManifest
      parameters: [{ $ref: '#/components/parameters/doorId' }]
      responses:
        '200':
          description: The manifest.
          content:
            application/json:
              schema:
                type: object
                required: [kind, version, label, capabilities, tier, endpoints, limits, notice]
                properties:
                  kind: { type: string, const: bubblio-agent-door }
                  version: { type: string }
                  label: { type: string }
                  capabilities:
                    type: array
                    items: { type: string, enum: [ask, about, act] }
                  tier: { type: string, const: anonymous }
                  endpoints:
                    type: object
                    properties:
                      mcp: { type: string, format: uri }
                      llms: { type: string, format: uri }
                      hub: { type: string, format: uri }
                  pricing:
                    type: array
                    description: Present only when the door has priced actions.
                    items:
                      type: object
                      properties:
                        action: { type: string }
                        amount_cents: { type: integer }
                        currency: { type: string }
                        display: { type: string }
                  payment:
                    type: object
                    description: 'Present with pricing: accepted rails + grant target (publishable key).'
                  limits:
                    type: object
                    properties:
                      asksPerHour: { type: integer }
                      asksPerDayPerCaller: { type: integer }
                      asksPerDayAcrossCallers: { type: integer }
                      quotesPerHour: { type: integer }
                      quotesPerDayPerCaller: { type: integer }
                      quotesPerDayAcrossCallers: { type: integer }
                  notice: { type: string }
                  docs: { type: string, format: uri }
        '404': { $ref: '#/components/responses/DoorMiss' }

  /agent/{doorId}/llms.txt:
    get:
      tags: [discovery]
      summary: A door's plain-text signpost
      operationId: getDoorLlmsTxt
      parameters: [{ $ref: '#/components/parameters/doorId' }]
      responses:
        '200':
          description: Plain text — tools, prices, caps, connect instructions, a bare-POST example.
          content:
            text/plain:
              schema: { type: string }
        '404': { $ref: '#/components/responses/DoorMiss' }

  /agent/{doorId}/mcp:
    get:
      tags: [mcp]
      summary: 'Stateless-JSON truth: nothing to GET'
      operationId: getDoorMcp
      parameters: [{ $ref: '#/components/parameters/doorId' }]
      responses:
        '405':
          description: POST your JSON-RPC messages.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CalmError' }
        '404': { $ref: '#/components/responses/DoorMiss' }
    post:
      tags: [mcp]
      summary: A door's MCP endpoint (JSON-RPC 2.0, one message per request)
      description: |
        Tools served: `about` and `ask` always; `list_actions`, `quote`, `confirm` only
        while the owner has ≥1 visible action. Tool-call RESULTS are MCP content — one
        `{ type: 'text', text }` block whose text is a JSON document; the important shapes
        are catalogued under components (QuoteAnswer, ConfirmAnswer, Receipt).

        The action lifecycle, in one paragraph: `quote { tool, args, agent_key? }` pins
        exactly what would run (10-minute TTL; for priced actions the pin IS the price
        guarantee) and answers a `quote_id`. `confirm { quote_id, agent_key? }` executes it
        EXACTLY once — the quote is consumed by a winner-only conditional update, and a
        duplicate confirm replays the original receipt (marked `replayed: true`) instead of
        re-executing. A keyed quote is BOUND to the credential that quoted: confirm must
        re-present the same key, or the quote reads as nonexistent — a quote_id alone is
        never a bearer token. Quotes needing human approval answer `status: needs_approval`
        with a `relay_script` for the human and an approval link; `confirm` doubles as the
        NON-consuming poll (`wait_seconds` ≤ 25) answering `awaiting_human` /
        `awaiting_payment` until the decision or settlement lands.
      operationId: postDoorMcp
      parameters: [{ $ref: '#/components/parameters/doorId' }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/JsonRpcRequest' }
      responses:
        '200':
          description: JSON-RPC result or protocol error.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/JsonRpcResponse' }
        '202': { description: 'Notification (no `id`): acknowledged, no body.' }
        '404': { $ref: '#/components/responses/DoorMiss' }
        '413':
          description: Body over 64KB.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/JsonRpcResponse' }
        '429':
          description: A published ceiling tripped (see the hub endpoint's note).
          headers:
            Retry-After: { schema: { type: integer } }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/JsonRpcResponse' }

  # ─── Approvals (the consent pages' API) ─────────────────────────────────────
  /approvals/a/{approvalId}:
    get:
      tags: [approvals]
      summary: The approval page's facts
      description: |
        Renders facts and writes NOTHING — link scanners and chat prefetchers GET links,
        so approval acts only on POST after a human gesture. Includes the mandate context
        (#37: `standing` = the live mandate's remaining budget, `offer` = the checkbox's
        server-authored terms) and, for connect ceremonies (#38), the connect step's state.
      operationId: getApproval
      parameters: [{ $ref: '#/components/parameters/approvalId' }]
      responses:
        '200':
          description: The approval's facts.
          content:
            application/json:
              schema:
                type: object
                required: [approval]
                properties:
                  approval:
                    type: object
                    required: [id, code, status, kind, platform, action, args, args_hash, tier, created_at, expires_at]
                    properties:
                      id: { $ref: '#/components/schemas/ApprovalId' }
                      code: { type: string, description: 'The short human-checkable code the agent relayed.' }
                      status: { type: string, enum: [pending, approved, declined, revoked, expired] }
                      kind: { type: string, enum: [action, connect] }
                      connect:
                        type: object
                        description: Connect ceremonies only.
                        properties:
                          connected: { type: boolean }
                          login_url: { type: [string, 'null'], format: uri }
                      platform: { type: string, description: "The door's display label." }
                      callback_origin: { type: [string, 'null'], description: "The platform's verified callback origin at mint time." }
                      action: { type: string }
                      args: { type: object }
                      args_hash: { type: string }
                      amount: { type: [string, 'null'], description: 'Display amount for priced actions.' }
                      agent:
                        type: object
                        properties:
                          presented: { type: [string, 'null'], description: 'The claimed Signature-Agent header, verbatim.' }
                          verified: { type: boolean, description: 'Whether Web Bot Auth PROVED the identity.' }
                          verified_agent: { type: string, description: 'The proven origin, present only when verified.' }
                      tier: { type: string, enum: [anonymous, keyed] }
                      created_at: { type: string, format: date-time }
                      expires_at: { type: string, format: date-time }
                      approved_at: { type: [string, 'null'], format: date-time }
                      mandate:
                        type: [object, 'null']
                        properties:
                          standing: { type: object, description: 'The live mandate covering this door, with remaining budget.' }
                          offer: { type: object, description: 'Server-authored terms the checkbox would mint.' }
        '404': { $ref: '#/components/responses/ApprovalMiss' }
        '429': { $ref: '#/components/responses/TooMany' }

  /approvals/a/{approvalId}/options:
    post:
      tags: [approvals]
      summary: Mint the WebAuthn ceremony options
      description: |
        mode `auth` (returning human: one passkey assertion) or `register` (the first
        FaceID: enrollment IS the approval). Both carry the SAME deterministic challenge —
        the SHA-256 of this approval's canonical terms — so whichever ceremony runs, the
        signature covers exactly what is being approved. A connect ceremony refuses to mint
        options until its grant holds the platform-minted key.
      operationId: postApprovalOptions
      parameters: [{ $ref: '#/components/parameters/approvalId' }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [mode]
              properties:
                mode: { type: string, enum: [auth, register] }
      responses:
        '200':
          description: WebAuthn creation/request options for the browser ceremony.
          content:
            application/json:
              schema: { type: object }
        '400': { $ref: '#/components/responses/CalmBadRequest' }
        '404': { $ref: '#/components/responses/ApprovalMiss' }
        '409':
          description: 'Already decided/expired, or the connect gate is not yet connected (`code: not_connected`).'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CalmError' }
        '429': { $ref: '#/components/responses/TooMany' }

  /approvals/a/{approvalId}/approve:
    post:
      tags: [approvals]
      summary: Approve — the human's passkey signature over the exact terms
      description: |
        Verifies the WebAuthn response against the terms-derived challenge and flips the
        approval (winner-only). `mandate: true` additionally mints the offered standing
        mandate — the ceremony and the grant are ONE signature, indivisible. Idempotent:
        re-approving an approved approval answers `{ ok, status: approved, already: true }`.
      operationId: postApprovalApprove
      parameters: [{ $ref: '#/components/parameters/approvalId' }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [mode, credential]
              properties:
                mode: { type: string, enum: [auth, register] }
                credential: { type: object, description: 'The WebAuthn registration/authentication response JSON.' }
                mandate: { type: boolean, description: 'Also mint the offered standing mandate (#37).' }
      responses:
        '200':
          description: Approved (or already approved).
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  status: { type: string }
                  already: { type: boolean }
        '400': { $ref: '#/components/responses/CalmBadRequest' }
        '404': { $ref: '#/components/responses/ApprovalMiss' }
        '409':
          description: 'Decided/expired, or the connect gate refused (`code: not_connected`).'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CalmError' }
        '429': { $ref: '#/components/responses/TooMany' }

  /approvals/a/{approvalId}/decline:
    post:
      tags: [approvals]
      summary: Decline (no ceremony needed — declining must always be easy)
      operationId: postApprovalDecline
      parameters: [{ $ref: '#/components/parameters/approvalId' }]
      responses:
        '200':
          description: Declined (idempotently).
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  status: { type: string, const: declined }
                  already: { type: boolean }
        '404': { $ref: '#/components/responses/ApprovalMiss' }
        '409':
          description: Already decided in another direction, or expired.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CalmError' }
        '429': { $ref: '#/components/responses/TooMany' }

  /approvals/session/options:
    post:
      tags: [approvals]
      summary: Mint a passkey sign-in challenge for the management surface
      description: Random single-use challenge (authentication, not consent-over-terms), 5-minute TTL.
      operationId: postSessionOptions
      responses:
        '200':
          description: WebAuthn request options.
          content:
            application/json:
              schema:
                type: object
                properties:
                  options:
                    type: object
                    properties:
                      challenge: { type: string }
                      rpId: { type: string }
                      timeout: { type: integer }
                      userVerification: { type: string }
                      allowCredentials: { type: array }
        '429': { $ref: '#/components/responses/TooMany' }

  /approvals/session:
    post:
      tags: [approvals]
      summary: Verify the sign-in assertion → consent token
      operationId: postSession
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [credential]
              properties:
                credential: { type: object, description: 'The WebAuthn authentication response JSON.' }
      responses:
        '200':
          description: The Bearer token for /approvals/mine.
          content:
            application/json:
              schema:
                type: object
                required: [token]
                properties:
                  token: { type: string }
        '400': { $ref: '#/components/responses/CalmBadRequest' }
        '429': { $ref: '#/components/responses/TooMany' }

  /approvals/mine:
    get:
      tags: [approvals]
      summary: Everything this passkey decided — the out-of-band kill-switch surface
      description: Connections, approval receipts, and standing mandates. Works WITHOUT any agent nearby.
      operationId: getMine
      security: [{ consentToken: [] }]
      responses:
        '200':
          description: The passkey's approvals + mandates (display metadata; degraded halves become empty lists, never page failures).
          content:
            application/json:
              schema: { type: object }
        '401':
          description: Sign in with your passkey first.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CalmError' }

  /approvals/mine/{approvalId}/revoke:
    post:
      tags: [approvals]
      summary: Revoke an approved connection
      description: Winner-only approved → revoked; effective at the very next quote/confirm (the door re-reads live, fail-closed). Idempotent.
      operationId: postRevokeApproval
      security: [{ consentToken: [] }]
      parameters: [{ $ref: '#/components/parameters/approvalId' }]
      responses:
        '200':
          description: Revoked (or already revoked).
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  status: { type: string }
                  already: { type: boolean }
        '401': { description: No consent session. }
        '404': { $ref: '#/components/responses/ApprovalMiss' }
        '409':
          description: Only an approved connection can be revoked.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CalmError' }

  /approvals/mine/mandates/{mandateId}/revoke:
    post:
      tags: [approvals]
      summary: Revoke a standing mandate (#37's one-tap kill-switch)
      description: |
        Winner-only active → revoked. Immediate at the door: the next quote skips the dead
        mandate, and a confirm on a quote it covered re-reads it LIVE pre-flight and
        refuses without consuming. Idempotent for already-dead mandates.
      operationId: postRevokeMandate
      security: [{ consentToken: [] }]
      parameters:
        - name: mandateId
          in: path
          required: true
          schema: { type: string, pattern: '^mnd_[a-f0-9]{32}$' }
      responses:
        '200':
          description: 'Revoked (or already in a terminal state, echoed with `already: true`).'
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  status: { type: string }
                  already: { type: boolean }
        '401': { description: No consent session. }
        '404':
          description: Uniform miss.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CalmError' }

  # ─── Managed agent keys ─────────────────────────────────────────────────────
  /v1/agent-keys:
    post:
      tags: [agent-keys]
      summary: Mint a managed agent key
      description: |
        Called by the PLATFORM'S SERVER (the SDK's `mintAgentKey` / `createAgentKeyRoute`).
        `ctx` is the platform-signed user-context JWT — Bubblio checks WELL-FORMEDNESS ONLY
        (three dot-separated base64url segments) and can neither verify nor forge it (it
        stores only a hash of the API key the signing secret derives from). Custody rules:
        the raw `bak_` key is returned ONCE and stored SHA-256; the ctx is encrypted at
        rest immediately and never logged.

        `max_active_per_user` (#49) is the opt-in per-user active-key quota, enforced
        INSIDE the mint: a pre-check refuses the common at-cap case before any write, and
        the post-insert count is the race-proof enforcement — concurrent mints can fail
        CLOSED (a spurious 409 a retry resolves) but can never overshoot the cap, PROVIDED
        every concurrent mint for that user carries the cap (the SDK's
        `createAgentKeyRoute` stamps it on every mint, grant path included). An UNCAPPED
        rival mint commits outside the enforcement and is never rolled back, so mixing
        capped and uncapped mints for one user can exceed the number the capped ones asked
        for.
      operationId: mintAgentKey
      security: [{ apiKey: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ctx]
              properties:
                ctx:
                  type: string
                  maxLength: 4096
                  pattern: '^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$'
                  description: The platform-signed user-context JWT (shape-checked only).
                label: { type: string, maxLength: 60 }
                externalUserId: { type: string, maxLength: 128 }
                userHint: { type: string, maxLength: 80, description: 'Display-only, e.g. a masked email.' }
                max_active_per_user:
                  type: integer
                  minimum: 1
                  maximum: 1000
                  description: Requires externalUserId — the cap counts one user's active keys.
      responses:
        '201':
          description: The raw key — returned exactly once, never retrievable again.
          content:
            application/json:
              schema:
                type: object
                required: [key, id]
                properties:
                  key: { type: string, description: 'bak_…' }
                  id: { type: string, format: uuid }
        '400': { $ref: '#/components/responses/CalmBadRequest' }
        '409':
          description: |
            `code: key_cap_reached` — the user is at the requested cap (revoke a key or
            raise the cap); or `code: migration_pending` while the keys table lags.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CalmError' }
        '429':
          description: The per-customer mint ceiling (default 30/hour).
          headers:
            Retry-After: { schema: { type: integer } }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CalmError' }
    get:
      tags: [agent-keys]
      summary: List this account's managed keys
      description: Display metadata ONLY — never hashes, never ctx, never the key.
      operationId: listAgentKeys
      security: [{ apiKey: [] }]
      parameters:
        - name: externalUserId
          in: query
          schema: { type: string }
          description: Filter to one of the platform's users.
      responses:
        '200':
          description: 'The keys (or `migrationPending: true` with an empty list while the table lags).'
          content:
            application/json:
              schema:
                type: object
                required: [keys]
                properties:
                  keys:
                    type: array
                    items: { $ref: '#/components/schemas/AgentKeyInfo' }
                  migrationPending: { type: boolean }

  /v1/agent-keys/{keyId}:
    delete:
      tags: [agent-keys]
      summary: Revoke a managed key
      description: |
        Idempotent and customer-scoped; the row is kept (the quote path needs `revoked_at`
        to refuse honestly), and the FIRST revoke's timestamp survives repeats. In-flight
        quotes made with the key refuse to confirm from this moment.
      operationId: revokeAgentKey
      security: [{ apiKey: [] }]
      parameters:
        - name: keyId
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Revoked (idempotently).
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  revoked: { type: string }
        '404':
          description: Not this customer's key (uniform with malformed ids).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CalmError' }

  /v1/agent-keys/connect-grant:
    post:
      tags: [agent-keys]
      summary: Forward a just-minted key into a waiting connect grant (#38)
      description: |
        The phone-connect flow's server half: the platform's key route calls this INSTEAD
        of returning a fresh key to the browser. The raw key must hash to the named row
        (proof the caller holds the very key it is connecting); the write stamps the
        waiting quote and its approval, then flips the grant LAST, winner-only. The key
        rests encrypted for its ONE delivery through the agent's poll. Idempotent for a
        retried POST of the SAME key; a different key against a granted grant is a 409
        (`code: conflict`) — one grant, one key, forever.
      operationId: postConnectGrant
      security: [{ apiKey: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [grantId, key, id]
              properties:
                grantId: { type: string, pattern: '^g_[a-f0-9]{32}$' }
                key: { type: string, description: 'The raw bak_ key the mint just returned.' }
                id: { type: string, format: uuid, description: "The minted key's row id." }
      responses:
        '200':
          description: 'Connected (with `already: true` on an idempotent repeat).'
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  already: { type: boolean }
        '400': { $ref: '#/components/responses/CalmBadRequest' }
        '404':
          description: 'Uniform miss: unknown, foreign, or malformed grant.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CalmError' }
        '409':
          description: 'Window closed (`code: expired`/`window_closed`), a different key already connected (`code: conflict`), or migration lag.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CalmError' }

  # ─── Action-registry sync ───────────────────────────────────────────────────
  /v1/agent-door/actions/sync:
    post:
      tags: [platform-sync]
      summary: Push code-declared action shapes (the SDK's door.sync)
      description: |
        SHAPES ONLY, never capability: synced actions always arrive with policy `off`, and
        `suggestedPolicy`/`approvalSuggestion` are displayed next to the dashboard control,
        never applied — which is exactly why API-key auth is allowed here. Idempotent;
        actions no longer declared are ORPHANED (flagged, never deleted); destructive-
        looking NAMES sync anyway with the suggestion dropped and a warning returned
        (machine callers must not 400 on a heuristic). Declaring `connectPath` sets the
        phone-connect page; `payments` sets the rails profile (PUBLISHABLE key only —
        sk_/rk_ values are refused by name); omitting either clears it.
      operationId: syncDoorActions
      security: [{ apiKey: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [callbackUrl, actions]
              properties:
                callbackUrl: { type: string, format: uri, description: 'https (http for localhost only).' }
                connectPath: { type: string, description: 'Path on the platform site, resolved against callbackUrl.' }
                payments:
                  type: object
                  properties:
                    rails:
                      type: array
                      items: { type: string, enum: [payment_link, stripe_spt] }
                    stripePublishableKey: { type: string, description: 'pk_… only.' }
                actions:
                  type: array
                  items:
                    type: object
                    required: [name, description, parameters]
                    properties:
                      name: { type: string, maxLength: 64 }
                      description: { type: string }
                      parameters: { type: object, description: 'JSON Schema (object/properties/required).' }
                      suggestedPolicy: { type: string, enum: [off, keyed, any] }
                      approvalSuggestion: { type: string, enum: [auto, mandate, always] }
                      price:
                        type: object
                        properties:
                          amount_cents: { type: integer, minimum: 1 }
                          currency: { type: string, const: usd }
                      timeoutSeconds: { type: integer }
      responses:
        '200':
          description: The sync result.
          content:
            application/json:
              schema:
                type: object
                properties:
                  synced: { type: integer }
                  orphaned: { type: integer }
                  unchanged: { type: integer }
                  pendingDecision: { type: integer }
                  warnings:
                    type: array
                    items: { type: string }
        '400': { $ref: '#/components/responses/CalmBadRequest' }

  # ─── Signed platform pushes ─────────────────────────────────────────────────
  /v1/agent-door/results/{quoteId}:
    post:
      tags: [platform-push]
      summary: Push a late result onto a quote (unreachable → ok)
      description: |
        The >20s escape hatch (the SDK's `pushDoorResult`; `createBubblioToolRoute` calls
        it automatically when a handler outlives the callback window). Requires the SIGNED
        push (API key + V2 signature over `${timestamp}.${rawBody}` with the platform's
        webhook secret) and the signed body must name this same quote — a captured push for
        quote A can never replay against quote B. Only `unreachable → ok` exists: an
        answered attempt is final, and a late FAILURE has nothing to push. The upgrade is
        APPEND-shaped (original receipt fields survive verbatim; `result_received_at`
        records the landing). A pushed `payment` summary must match the quote's pinned
        price exactly — amounts on receipts are never rewritable — and may never carry a
        credential (any surplus field is refused).
      operationId: pushDoorResult
      security: [{ apiKey: [] }]
      parameters:
        - { $ref: '#/components/parameters/quoteId' }
        - { $ref: '#/components/parameters/signatureV2' }
        - { $ref: '#/components/parameters/timestamp' }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [quoteId, result]
              properties:
                quoteId: { $ref: '#/components/schemas/QuoteId' }
                result: { type: object, description: '≤32KB serialized — refused (not truncated) over the cap.' }
                payment: { $ref: '#/components/schemas/PaymentSummary' }
      responses:
        '200':
          description: Receipt upgraded unreachable → ok.
          content:
            application/json:
              schema: { type: object }
        '400': { $ref: '#/components/responses/CalmBadRequest' }
        '404':
          description: |
            UNIFORM: unknown quote, malformed id, stale/absent timestamp, or a signature
            that did not verify — all read "Nothing to resolve here." (Probing resistance:
            a rotation-stale secret is indistinguishable from a wrong quote id; the SDK
            refreshes its cached secret once and retries on this answer.)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CalmError' }
        '409':
          description: '`code: already_resolved` (benign — the callback made it in time), `not_confirmed`, or `final_outcome`.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CalmError' }
        '413':
          description: Body over 64KB / result over 32KB.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CalmError' }

  /v1/agent-door/paid/{quoteId}:
    post:
      tags: [platform-push]
      summary: Mark a quote's payment settled (the payment-link rail's resume, #41)
      description: |
        Called from the platform's `checkout.session.completed` webhook (the SDK's
        `resumePaidQuote` / `completeBubblioCheckout`). Same signed-push authentication as
        the results endpoint. Bubblio refuses a settlement whose `args_hash` or amount
        doesn't match the quote's pin — the money must buy exactly the pinned call — and
        every refund-worthy refusal means the payment bought nothing (the SDK auto-refunds
        it on the platform's own Stripe). After a settle, the visiting agent's next confirm
        poll executes the action, payment already made.
      operationId: resumePaidQuote
      security: [{ apiKey: [] }]
      parameters:
        - { $ref: '#/components/parameters/quoteId' }
        - { $ref: '#/components/parameters/signatureV2' }
        - { $ref: '#/components/parameters/timestamp' }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [quoteId, payment]
              properties:
                quoteId: { $ref: '#/components/schemas/QuoteId' }
                args_hash: { type: string, description: "The session's metadata.args_hash — must match the pin." }
                payment: { $ref: '#/components/schemas/PaymentSummary' }
      responses:
        '200':
          description: 'Settled (with `already: true` on a webhook redelivery — benign).'
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  already: { type: boolean }
        '400':
          description: Malformed, or a coded mismatch (`amount_mismatch`, `args_mismatch`).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CalmError' }
        '404':
          description: Uniform miss (unknown quote / failed signature), same doctrine as the results push.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CalmError' }
        '409':
          description: 'Coded refusal: `expired`, `already_settled`, `not_payable`, or `conflict` (transient race — the webhook redelivery decides).'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CalmError' }
        '413':
          description: Body over 16KB.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CalmError' }

# ─── The outbound half: Bubblio → platform callbacks ──────────────────────────
webhooks:
  toolCallback:
    post:
      tags: [platform-push]
      summary: The signed action/tool callback POSTed to the platform's callbackUrl
      description: |
        What `createBubblioToolRoute` receives and verifies. Signed twice for one
        deprecation window — the replay-protected `X-Bubblio-Signature-V2` (HMAC-SHA256
        over `${timestamp}.${rawBody}`) is the one to verify; the legacy body-only
        `X-Bubblio-Signature` is replayable and refused by SDK ≥0.9 unless explicitly
        allowed. Delivered AT MOST ONCE per quote — there is no retry loop, and a duplicate
        confirm replays the stored receipt without reaching the platform (#49: handler-side
        replay caches are defense-in-depth, not the primary double-execution defense).

        Bubblio waits at most `min(timeoutSeconds ?? 10, 20)` seconds; a slower handler
        strands the receipt at `unreachable` until a signed results push resolves it.

        Expected responses: 200 with the JSON result (outcome `ok`); any non-2xx finalizes
        the receipt as `platform_rejected`. For a `paymentLinkRequest` body (a quote-time
        mint, no tool dispatch), answer `{ url, ref }`.
      parameters:
        - name: X-Bubblio-Signature-V2
          in: header
          required: true
          schema: { type: string }
        - name: X-Bubblio-Timestamp
          in: header
          required: true
          schema: { type: string }
        - name: X-Bubblio-Signature
          in: header
          schema: { type: string }
          description: Legacy body-only signature (compat; do not verify against it).
        - name: X-Bubblio-Session
          in: header
          schema: { type: string }
        - name: X-Bubblio-Tool
          in: header
          schema: { type: string }
        - name: X-Bubblio-Dangerous
          in: header
          schema: { type: string, const: 'true' }
          description: Present on tools the platform classified destructive.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CallbackBody' }
      responses:
        '200':
          description: The handler's JSON result — stored on the receipt (≤32KB).

components:
  securitySchemes:
    apiKey:
      type: http
      scheme: bearer
      description: The platform's Bubblio API key (`bbl_…`). Server-side only.
    consentToken:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: The human's passkey-session token from `POST /approvals/session`.

  parameters:
    doorId:
      name: doorId
      in: path
      required: true
      schema: { $ref: '#/components/schemas/DoorId' }
    approvalId:
      name: approvalId
      in: path
      required: true
      schema: { $ref: '#/components/schemas/ApprovalId' }
    quoteId:
      name: quoteId
      in: path
      required: true
      schema: { $ref: '#/components/schemas/QuoteId' }
    signatureV2:
      name: X-Bubblio-Signature-V2
      in: header
      required: true
      schema: { type: string }
      description: '`sha256=<hmac>` over `${timestamp}.${rawBody}` with the webhook secret.'
    timestamp:
      name: X-Bubblio-Timestamp
      in: header
      required: true
      schema: { type: string }
      description: Unix milliseconds; ±5 minutes tolerated.

  responses:
    DoorMiss:
      description: 'UNIFORM: unknown, malformed, disabled, unapproved, and migration-lagged doors all read the same.'
      content:
        application/json:
          schema:
            type: object
            properties:
              error: { type: string, const: 'No agent door here.' }
    ApprovalMiss:
      description: 'UNIFORM: unknown, malformed, foreign, and lagged approvals all read the same.'
      content:
        application/json:
          schema:
            type: object
            properties:
              error: { type: string, const: 'No approval here.' }
    CalmBadRequest:
      description: One calm sentence naming the malformed field.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/CalmError' }
    TooMany:
      description: Per-IP ceiling tripped.
      headers:
        Retry-After: { schema: { type: integer } }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/CalmError' }

  schemas:
    DoorId: { type: string, pattern: '^door_[a-f0-9]{12}$' }
    ApprovalId: { type: string, pattern: '^apr_[a-f0-9]{32}$' }
    QuoteId: { type: string, pattern: '^q_[a-f0-9]{16}(?:[a-f0-9]{16})?$' }

    CalmError:
      type: object
      required: [error]
      properties:
        error: { type: string }
        code:
          type: string
          description: Machine-readable refusal code, present where documented — branch on this, never on message text.

    JsonRpcRequest:
      type: object
      required: [jsonrpc, method]
      properties:
        jsonrpc: { type: string, const: '2.0' }
        id:
          type: [string, number, 'null']
          description: Omit entirely for a notification (answered 202, no body).
        method: { type: string, enum: [initialize, ping, tools/list, tools/call] }
        params:
          type: object
          description: 'For tools/call: { name, arguments }.'

    JsonRpcResponse:
      type: object
      required: [jsonrpc, id]
      properties:
        jsonrpc: { type: string, const: '2.0' }
        id: { type: [string, number, 'null'] }
        result:
          type: object
          description: |
            For tools/call: MCP content — `{ content: [{ type: 'text', text }], isError? }`
            where `text` is a JSON document (see QuoteAnswer / ConfirmAnswer / Receipt for
            the action tools). Tool FAILURES arrive here with `isError: true`, phrased for
            the calling model; JSON-RPC `error` is reserved for protocol problems.
        error:
          type: object
          properties:
            code: { type: integer }
            message: { type: string }

    QuoteAnswer:
      type: object
      description: |
        The JSON inside a successful `quote` tool result's text block. The pin is the
        contract: `quote_id` + these exact args are what confirm will execute, and for
        priced actions the pinned price is what will be charged — never anything the
        agent sends later. `expires_at` is ~10 minutes out; the TTL is the price-guarantee
        window and the approval/payment-link window alike.
      required: [quote_id, tool, args, expires_at, notice]
      properties:
        quote_id: { $ref: '#/components/schemas/QuoteId' }
        tool: { type: string }
        args: { type: object }
        status:
          type: string
          description: '`needs_approval` when a human must decide first; absent for immediately-confirmable quotes.'
        kind:
          type: string
          description: '`connect` on the phone-connect variant (#38): the agent has no key yet and the human signs in on the platform first.'
        approval:
          type: object
          description: Present with needs_approval — the link + short code to RELAY to the human.
          properties:
            url: { type: string, format: uri }
            code: { type: string }
            expires_at: { type: string, format: date-time }
        poll_token:
          type: string
          description: |
            Connect quotes only: the quote's credential. The agent presents it as
            `agent_key` on every confirm poll, and the completing poll trades it for the
            minted bak_ key exactly once. PRIVATE — never relayed or displayed.
        relay_script:
          type: string
          description: Text to forward to the human EXACTLY as given (the link must stay tappable).
        mandate:
          type: object
          description: 'Step-up context (#37): why a standing mandate did not cover this run, remaining budget included.'
        price:
          type: object
          description: 'Priced actions: the pinned amount (echo of the registry price).'
        payment:
          type: object
          description: 'Priced actions: the payment profile (rails + grant target).'
        payment_challenge:
          type: object
          description: 'payment_link rail: { type: payment_link, url, ref?, expires_at } — the human pays here.'
        expires_at: { type: string, format: date-time }
        notice: { type: string }

    ConfirmAnswer:
      type: object
      description: |
        The JSON inside a `confirm` tool result's text block, once the action executed:
        `{ result, receipt }` (with `receipt.replayed: true` on a duplicate confirm).
        While the quote is not yet executable, confirm answers a status object instead:
        `awaiting_human` (approval pending) or `awaiting_payment` (payment-link not yet
        settled), each with a suggested `retry_in` — polling never consumes the quote.
        Declines, expiries, and pre-flight refusals arrive as `isError` tool results whose
        text names the reason and, where a receipt exists, carries it.
      properties:
        result: { type: object, description: "The platform handler's stored result." }
        receipt: { $ref: '#/components/schemas/Receipt' }

    Receipt:
      type: object
      description: The append-only execution record both the agent and the dashboard hold.
      required: [receipt_id, tool, args_hash, tier, confirmed_at, outcome]
      properties:
        receipt_id: { $ref: '#/components/schemas/QuoteId' }
        tool: { type: string }
        args_hash: { type: string }
        tier: { type: string, enum: [anonymous, keyed] }
        approval_id: { $ref: '#/components/schemas/ApprovalId' }
        approved_at: { type: [string, 'null'], format: date-time }
        mandate_id: { type: string, description: 'The standing grant that authorized a silent run (#37).' }
        price_cents: { type: integer, description: 'The pinned price the ledger recorded (#39).' }
        currency: { type: string }
        payment: { $ref: '#/components/schemas/PaymentSummary' }
        confirmed_at: { type: string, format: date-time }
        outcome:
          type: string
          enum: [ok, platform_rejected, unreachable]
          description: '`unreachable` upgrades to `ok` only via the signed results push.'
        replayed: { type: boolean, description: 'Present on duplicate-confirm replays.' }
        result_received_at:
          type: string
          format: date-time
          description: Stamped when a late result push upgraded this receipt.

    PaymentSummary:
      type: object
      description: The settled charge as receipts carry it — facts only, never a credential.
      required: [rail, amount_cents, currency, ref]
      properties:
        rail: { type: string, examples: [stripe_spt, payment_link] }
        amount_cents: { type: integer }
        currency: { type: string }
        ref: { type: string, description: 'The provider reference (Stripe PaymentIntent / Checkout Session id).' }

    AgentKeyInfo:
      type: object
      description: One managed key as the list reports it — never the key, a hash, or the ctx.
      properties:
        id: { type: string, format: uuid }
        label: { type: [string, 'null'] }
        externalUserId: { type: [string, 'null'] }
        userHint: { type: [string, 'null'] }
        createdAt: { type: string, format: date-time }
        lastUsedAt: { type: [string, 'null'], format: date-time }
        revokedAt: { type: [string, 'null'], format: date-time }

    CallbackBody:
      type: object
      description: |
        The V2-signed JSON Bubblio POSTs to the platform's callbackUrl (mirrors the SDK's
        CallbackBody). Widget-session tool callbacks carry only tool/args (+ sessionId);
        the other fields are Agent Door facts.
      required: [tool, args]
      properties:
        tool: { type: string }
        args: { type: object }
        agent:
          type: object
          description: The visiting-agent identity block on Agent Door confirms.
          properties:
            tier: { type: string, enum: [keyed, anonymous] }
            keyId: { type: string, description: 'Managed-key path: the bak_ key row id (the raw key never travels).' }
            key: { type: string, description: 'Custom-custody path only: the platform-minted raw key, passed through verbatim and never validated by Bubblio.' }
            verified_agent: { type: string, description: 'The Web-Bot-Auth-VERIFIED operator origin, when the quote proved one (#42).' }
        ctx:
          type: string
          description: |
            The platform-signed user-context JWT, inside the signed body (≥0.10; also
            echoed as `?ctx=` on the URL for a deprecation window). Bubblio stores it
            encrypted and cannot verify or forge it.
        idempotencyKey:
          type: string
          description: |
            The quote id, on Agent Door confirms — the charge idempotency key and the
            handle a timed-out quote is resolved by (`snake_case` alias
            `idempotency_key` tolerated). Delivered at most once per quote — see the
            webhook description for the honest framing.
        callbackTimeoutSeconds:
          type: number
          description: How long Bubblio waits on THIS delivery before recording `unreachable` (platforms ≥0.10).
        payment:
          type: object
          description: |
            The charge instruction on a PAID confirm — `{ rail, token, amount_cents,
            currency, args_hash? }`. Amount/currency are the QUOTE'S PIN (the agent can
            never set an amount); the token is transit-only and must never be stored or
            logged. Raw card numbers are hard-refused at both ends.
        payment_settled:
          $ref: '#/components/schemas/PaymentSummary'
          description: Settlement FACTS on a confirm whose payment already landed out-of-band (payment-link) — nothing to charge.
        paymentLinkRequest:
          type: object
          description: |
            A quote-time request to mint the Stripe Checkout link on the PLATFORM'S OWN
            account — `{ quoteId, amount_cents, currency, expires_at?, label?, tool?,
            args_hash? }`. Handled before tool dispatch; the expected answer is
            `{ url, ref }`.
