openapi: 3.1.0

info:
  title: AIConnect API — v1
  version: "1"
  license:
    # `name` is the only REQUIRED field of an OpenAPI 3.1 license object; `url` is optional and
    # nothing consumes it (measured: zero readers in backend or web).
    #
    # THE URL WAS DROPPED, NOT REPOINTED (2026-08-09). It read `https://aiconnect.dev/terms` — a
    # domain we do not own (S14 measured `000 exit=28`). The obvious fix was `anywe.dev/terms`,
    # but that 404s (measured, same day), so repointing would have moved a dead link onto a domain
    # we DO own — trading someone else's broken promise for our own. Same call as the CLI install
    # line: a link that resolves to nothing is not made honest by owning the host. Restore a `url`
    # when a real terms page exists.
    name: UNLICENSED
  description: |
    The AIConnect control-plane API for Sprint 1: passwordless authentication, agent registry
    management, and the agent-facing reply endpoint.

    ## Two surfaces, one envelope grammar

    Every response on both surfaces uses the same shapes — a list envelope
    (`items`/`count`/`total`/`truncated`/`nextCursor`), a get envelope (`found`/`item`/`hint`), and
    one error object (`code`/`message`/`hint`/`retryable`). A caller who learns those three can
    read any response the platform produces.

    Field NAMES differ by surface, deliberately, and each side cites its own authority:

    - **Agent-facing** (`/hooks/v1/*`, the manifest document, blocks) is `snake_case`, because
      Architecture.md §8.1/§8.2 publish it that way and third-party developers integrate against
      it directly.
    - **Human-facing** (`/v1/*`, consumed by the web console) is `camelCase`, because
      TOOL-DESIGN.md Pillar 2 specifies `nextCursor` / `*At` literally and SPEC.md §3b cites that
      envelope verbatim.

    The manifest document keeps `snake_case` even when submitted through a human-facing endpoint:
    it is the agent contract wherever it appears, and re-casing it for transport would mean the
    console and the agent see different documents.

    ## Frozen contract

    The two surfaces carry different change pressure, and conflating them over-commits the
    human-facing half:

    - **`/hooks/v1/*` and the manifest document are FROZEN.** Architecture.md §2 invariant 6 makes
      the agent-facing wire contract a public promise to third-party developers. Fields may be
      ADDED; they are never removed, renamed or re-typed, and an optional field never becomes
      required.
    - **`/v1/*` is versioned but not frozen.** It is consumed by this project's own
      generated-type frontends (Architecture.md §5, thin membranes), so it sits outside invariant
      6. Additive discipline still applies — one envelope grammar, the SPEC.md §3b shapes — but a
      change there costs a coordinated release rather than a contract version.

servers:
  # Local development is the ONLY environment that exists today: no deploy target has been chosen
  # (DECISIONS.md, resource checklist). Listing a production URL that does not resolve would be
  # worse than listing none — a developer would trust it and get a connection error instead of a
  # clear "this is local-only". Redocly warns about `localhost` here; the warning is accurate and
  # the answer is "not yet", not "suppress it". A real origin replaces this the sprint a deploy
  # target is decided.
  - url: http://localhost:8080
    description: Local development. Replace when a deploy target exists.

tags:
  - name: auth
    description: Passwordless sign-in. No passwords exist anywhere in this API by design.
  - name: registry
    description: Agent lifecycle — create, update, publish, credential rotation.
  - name: policy
    description: The approval engine's caller-facing surface — list and resolve pending tier-2 approvals (Architecture.md 7.5).
  - name: hooks
    description: Agent-facing endpoints. Authenticated by HMAC signature, not by session.

security:
  - sessionCookie: []

paths:
  /v1/auth/otp/request:
    post:
      tags: [auth]
      operationId: requestOtp
      summary: Send a one-time sign-in code to an email address.
      security: []
      description: |
        Emails a short-lived code. The response is IDENTICAL whether or not an account exists for
        the address — varying it would let anyone test which emails are registered.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: ./auth.schema.json#/$defs/otpRequest
      responses:
        "200":
          description: The request was accepted. Not a claim that an account exists.
          content:
            application/json:
              schema:
                $ref: ./auth.schema.json#/$defs/otpRequestResult
        "400":
          $ref: "#/components/responses/BadRequest"
        "429":
          $ref: "#/components/responses/RateLimited"

  /v1/auth/otp/verify:
    post:
      tags: [auth]
      operationId: verifyOtp
      summary: Exchange a one-time code for a session.
      security: []
      description: |
        On success, sets an httpOnly, SameSite session cookie. The session identifier is never
        returned in the body: a token readable by JavaScript is a token stealable by injected
        script.

        EVERY failure — unknown address, wrong code, expired code, exhausted attempts — returns
        the single code `otp_verification_failed`. Distinguishing them would rebuild the
        account-enumeration oracle the request endpoint is designed to prevent.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: ./auth.schema.json#/$defs/otpVerify
      responses:
        "200":
          description: Session established; the session cookie is set.
          content:
            application/json:
              schema:
                $ref: ./auth.schema.json#/$defs/sessionResult
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          description: Verification failed. Indistinguishable across all causes, by design.
          content:
            application/json:
              schema:
                $ref: ./common.schema.json#/$defs/error
        "429":
          $ref: "#/components/responses/RateLimited"

  /v1/auth/session:
    get:
      tags: [auth]
      operationId: getSession
      summary: Describe the caller's current session.
      security: []
      description: |
        Returns `found: false` with 200 when the caller has no session. Absence is a normal answer
        to "do I have one", not a failure of the request.
      responses:
        "200":
          description: "Session state. `found` distinguishes signed-in from signed-out."
          content:
            application/json:
              schema:
                $ref: ./auth.schema.json#/$defs/sessionResult
        "400":
          $ref: "#/components/responses/BadRequest"

  /v1/auth/logout:
    post:
      tags: [auth]
      operationId: logout
      summary: End the caller's session.
      security: []
      description: |
        Idempotent: signing out without a session succeeds, because the caller's desired end state
        is already true.
      responses:
        "200":
          description: The caller has no session, whether or not one existed.
          content:
            application/json:
              schema:
                $ref: ./auth.schema.json#/$defs/logoutResult
        "400":
          $ref: "#/components/responses/BadRequest"

  /v1/auth/oauth/google/start:
    post:
      tags: [auth]
      operationId: googleOAuthStart
      summary: Begin a Google sign-in — mint the state token and redirect to consent.
      security: []
      description: |
        PUBLIC BY NECESSITY, like the callback: the caller has no session yet, and obtaining one is
        the point.

        THE SERVER BUILDS THE URL AND SETS THE COOKIE IN ONE RESPONSE (DECISIONS.md, S8 kickoff).
        The alternative — returning `{authorizationUrl, state}` for the browser to navigate itself —
        was rejected for three reasons: it copies `redirect_uri` and `scope` into a response body,
        creating a second place they can disagree with the server's validated configuration; it
        needs two round trips, so a browser taking the second without the first produces a `state`
        mismatch indistinguishable from a real attack; and it does not match the callback, which
        already compares against a cookie this server set.

        THE CLIENT SENDS NO BODY AND NAMES NOTHING. There is no request payload at all — not an
        account, not a redirect target, not a scope. Every value in the resulting URL comes from
        server configuration, because a caller who could name any of them could aim a user's browser
        or widen this application's consent request.

        RATE LIMITED per network origin. This endpoint has no cheap gate of its own: every request
        mints a CSPRNG token and sets a cookie, so the limiter is its floor.
      responses:
        "303":
          description: |
            The sign-in has begun. `Location` carries Google's consent URL with `client_id`,
            `redirect_uri`, `response_type`, `scope` and `state`; `Set-Cookie` carries the same
            `state` value, and matching the two at the callback is what proves the sign-in was
            started by this browser.

            303 rather than 302 so the browser switches to GET for Google's endpoint regardless of
            the method that arrived here.
          headers:
            Location:
              description: Google's authorization URL for this attempt.
              schema:
                type: string
                format: uri
            Set-Cookie:
              description: The single-use `state` cookie, HttpOnly and SameSite=Lax.
              schema:
                type: string
        "429":
          description: |
            Too many sign-in attempts from this network origin — `rate_limited`, retryable.
          content:
            application/json:
              schema:
                $ref: ./common.schema.json#/$defs/error
        "503":
          description: |
            Google sign-in is not configured on this deployment — `oauth_provider_unavailable`.
            Fail-closed: redirecting to a URL built from empty credentials would strand the person
            on Google's error page for our own misconfiguration.
          content:
            application/json:
              schema:
                $ref: ./common.schema.json#/$defs/error

  /v1/auth/oauth/google/callback:
    post:
      tags: [auth]
      operationId: googleOAuthCallback
      summary: Exchange a Google authorization code for a session.
      security: []
      description: |
        PUBLIC BY NECESSITY: the caller has no session yet — obtaining one is the point. It is not
        unauthenticated in the meaningful sense, because the `state` token proves the request
        continues a sign-in this server started, and the `code` is verified with Google
        server-side.

        THE CLIENT NEVER NAMES AN ACCOUNT. The body carries no email and no user id; the identity
        comes from the `sub` claim of the ID token the server fetches from Google. `sub` is stable
        across a Google email change, which an email is not — a subject-as-email design silently
        re-points at a different account later.

        LINKING (DECISIONS.md 2026-08-06, ruled, §3 irreversible class): when the Google address
        matches an existing `email_otp` account, the identities are linked ONLY if Google asserts
        `email_verified: true`. Otherwise a SEPARATE user is created. Treating an address Google
        refuses to vouch for as proof of identity is an account-takeover vector; the response's
        `linked_to_existing_account` is what makes the outcome observable rather than silent.

        A DECLINED CONSENT IS A 200, not an error — see `oauthConsentDenied`. The person made a
        choice and every component behaved correctly.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              oneOf:
                - $ref: ./auth.schema.json#/$defs/oauthCallback
                - $ref: ./auth.schema.json#/$defs/oauthConsentDenied
      responses:
        "200":
          description: |
            Either a session was established (cookie set) or the user declined consent. The
            `outcome` field distinguishes them; both are successful completions of the request.
          content:
            application/json:
              schema:
                $ref: ./auth.schema.json#/$defs/oauthCallbackResult
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          description: |
            The state token or the authorization code was rejected — `oauth_state_invalid` or
            `oauth_code_invalid`. Deliberately indistinguishable within each code: naming which
            half of the check failed helps only an attacker probing a victim's browser.
          content:
            application/json:
              schema:
                $ref: ./common.schema.json#/$defs/error
        "502":
          description: |
            Google could not be reached or answered unusably — `oauth_provider_unavailable`, the
            only retryable failure of the three.
          content:
            application/json:
              schema:
                $ref: ./common.schema.json#/$defs/error

  /v1/me/wallet/topup-intents:
    post:
      tags: [treasury]
      summary: Start a top-up attempt
      description: |-
        Creates a top-up intent in `requires_confirmation`. NOTHING HAS BEEN ATTEMPTED and no coin is
        reserved — this is the payment sheet opening, not a payment.

        🔴 THE PROVIDER IS A MOCK, AND IT TEACHES THE REAL GRAMMAR RATHER THAN SHORTCUTTING IT.
        A real card provider is create → confirm → asynchronous outcome, so this platform is too: the
        confirm route does NOT credit inline, a background job settles, and `processing` is a real
        interval a client must render. Instant credit would hide all three until the day a real
        provider is wired — the moment they are most expensive to discover.

        🔴 NO CARD DATA IS EXPRESSIBLE HERE. The body carries an amount and nothing else, and
        `additionalProperties: false` means a card number cannot be smuggled in under any name. Card
        details are not rejected — they are unspeakable. When a real provider is wired (§1-gated:
        dependency approval, keys, DECISIONS go-ahead), its client-side SDK collects them and this
        platform still never receives them, so this shape already suits that world.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: ./treasury.schema.json#/$defs/createTopupIntentBody
      responses:
        "201":
          description: The intent, awaiting confirmation.
          content:
            application/json:
              schema:
                $ref: ./treasury.schema.json#/$defs/topupIntentResult
        "400":
          description: The amount is out of range or the body is malformed.
          content:
            application/json:
              schema:
                $ref: ./common.schema.json#/$defs/error
        "401":
          description: No session.
          content:
            application/json:
              schema:
                $ref: ./common.schema.json#/$defs/error

  /v1/me/wallet/topup-intents/{intentId}:
    get:
      tags: [treasury]
      summary: Read one top-up attempt
      description: |-
        The intent's current state — how a client observes the asynchronous outcome after confirming.

        POLLING IS THE OBSERVATION MECHANISM for this slice, deliberately chosen over pushing the
        state down the SSE stream: a payment sheet is open and foregrounded while it waits, so the
        client is already awake and asking. Adding a stream event would put a money state on a
        transport whose delivery this platform does not guarantee to a backgrounded app.

        🔴 OWNER-SCOPED, AND ABSENCE IS INDISTINGUISHABLE FROM SOMEONE ELSE'S. An intent that does
        not exist and one belonging to another user both answer `200 {found:false}` — byte-identical.
        A 404 here would separate the two and turn this route into a probe for which intent ids
        exist, which is the existence oracle B2C closed platform-wide.
      parameters:
        - name: intentId
          in: path
          required: true
          schema:
            type: string
            pattern: "^tup_[0-9A-HJKMNP-TV-Z]{26}$"
      responses:
        "200":
          description: The intent.
          content:
            application/json:
              schema:
                $ref: ./treasury.schema.json#/$defs/topupIntentResult
        "401":
          description: No session.
          content:
            application/json:
              schema:
                $ref: ./common.schema.json#/$defs/error

  /v1/me/wallet/topup-intents/{intentId}/confirm:
    post:
      tags: [treasury]
      summary: Authorise a top-up attempt
      description: |-
        The human pressed pay. Moves the intent to `processing` and hands it to the settle worker;
        it does NOT credit coin inline.

        IDEMPOTENT: confirming the same intent twice credits once. The second call finds the intent
        already out of `requires_confirmation` and answers with its current state rather than
        starting a second attempt — so a double-tapped button, a retried request and a flaky network
        all resolve to one payment.

        A CONFIRMED INTENT IS NEVER RE-OPENED. Terminal states are terminal; a retry after a decline
        is a NEW intent, which keeps an audit trail of attempts instead of one mutable row that
        forgets how many times a payment was tried.
      parameters:
        - name: intentId
          in: path
          required: true
          schema:
            type: string
            pattern: "^tup_[0-9A-HJKMNP-TV-Z]{26}$"
      responses:
        "200":
          description: The intent, now processing (or its terminal state if already settled).
          content:
            application/json:
              schema:
                $ref: ./treasury.schema.json#/$defs/topupIntentResult
        "401":
          description: No session.
          content:
            application/json:
              schema:
                $ref: ./common.schema.json#/$defs/error

  /v1/notifications:
    get:
      tags: [notifications]
      summary: The caller's own alerts, newest first
      description: |-
        What happened that the signed-in person may want to act on: messages landing in rooms they
        participate in, and approvals awaiting their decision.

        EVERY ROW IS DERIVED AT READ TIME, NEVER STORED. There is no notifications table — rows are
        computed from the events themselves, so an alert cannot outlive or contradict its source: a
        redacted message stops appearing, a resolved approval drops out, and a renamed room shows its
        new name in an old alert. A stored copy would drift from all three.

        CURSOR-PAGED, NEVER OFFSET. The cursor is an (occurredAt, id) position, so a page costs the
        same at any depth and is stable while new events arrive — an offset would re-scan everything
        before it and shift every later page as rows land mid-scroll.

        OWNERSHIP IS THE SESSION'S. There is no path or query parameter that could name another user;
        the question is inexpressible rather than refused.

        MARK-READ IS NOT HERE. That is a write, deliberately out of scope until ruled, so `read` is
        always false today — see its own description, which says so rather than letting a client
        infer a writer exists.
      parameters:
        - name: cursor
          in: query
          required: false
          schema:
            type: string
            maxLength: 512
          description: |-
            Opaque position from a previous page's `nextCursor`. Omit for the newest page. A cursor
            that cannot be read is a 400 rather than a silent reset to the top — a reset would look
            like the list ended and restarted, and the client would show duplicates without learning
            why.
        - name: limit
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 100
          description: Rows per page. Defaults to 30; values above 100 are clamped.
      responses:
        "200":
          description: One page of the caller's notifications.
          content:
            application/json:
              schema:
                $ref: ./notifications.schema.json#/$defs/notificationList
        "400":
          description: The cursor or limit could not be read.
          content:
            application/json:
              schema:
                $ref: ./common.schema.json#/$defs/error
        "401":
          description: No session.
          content:
            application/json:
              schema:
                $ref: ./common.schema.json#/$defs/error

  /v1/me/wallet:
    get:
      tags: [treasury]
      summary: The caller's own coin balance
      description: |-
        The signed-in user's wallet balance, in WHOLE COINS.

        ZERO IS SENT, NEVER OMITTED, AND NEVER A 404. A user who has never touched coin gets
        `{"balanceCoin": 0}` — every signed-in user HAS a wallet conceptually, and the absence of a
        row is a balance of zero rather than the absence of a wallet. This is the opposite of the
        omit-not-zero rule the metrics reads follow, and the distinction is the one that ruling
        draws: omit-not-zero governs RATES, where a zero is indistinguishable from "no samples" and
        renders a fact nobody measured. A COUNT has no such ambiguity — zero coin is measured, true
        and actionable, and hiding it would render an honest-looking dash for exactly the users
        whose balance is most certain.

        THE ROUTE HAS NO PARAMETER THAT COULD NAME ANOTHER USER. Ownership is the session's, not the
        request's — not because a check refuses a foreign id, but because there is nowhere to put
        one. A shape that cannot express the question is stronger than a guard that could be
        forgotten.

        READ-ONLY, AND IT DOES NOT CREATE A ROW. A wallet row is written by the first movement of
        coin, which is the only event that makes one meaningful; a read that writes would turn every
        page refresh into an INSERT.

        Coin is non-convertible beta scrip (DECISIONS T12): an indivisible integer with no currency
        and no minor units. There is no top-up here — the read alone closes the user's loop.
      responses:
        "200":
          description: The caller's balance.
          content:
            application/json:
              schema:
                $ref: ./treasury.schema.json#/$defs/walletResult
        "401":
          description: No session.
          content:
            application/json:
              schema:
                $ref: ./common.schema.json#/$defs/error

  /v1/publishers/{publisherId}/verify:
    post:
      tags: [registry]
      operationId: verifyPublisher
      summary: Mark a publisher verified (operator action).
      description: |
        Tier 2 of DEBT.md item 1: an operator stamps `verified_at`, and THE HUMAN IS THE CHECK.
        This is not KYC (tier 3, undesigned, not in this sprint) — it is a person deciding that a
        publisher may put agents in front of strangers.

        IDEMPOTENT (defensive case 6): verifying an already-verified publisher answers 200 with
        `already_verified: true`, never a 409. The FIRST approval's timestamp survives; a repeat
        does not re-stamp it, because a second click must not silently rewrite when the human
        decision happened.

        AUTHORIZATION: operator-only. A caller who holds a session but not that right receives
        `not_authorized` (403) — the same answer as for a publisher that does not exist, so this
        endpoint cannot be used to discover which publisher ids are real.
      parameters:
        - name: publisherId
          in: path
          required: true
          schema:
            $ref: ./common.schema.json#/$defs/publisherId
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: ./registry.schema.json#/$defs/verifyPublisherBody
      responses:
        "200":
          description: Publisher is verified. `already_verified` reports whether this call changed anything.
          content:
            application/json:
              schema:
                $ref: ./registry.schema.json#/$defs/verifyPublisherResult
        "400":
          $ref: "#/components/responses/BadRequest"
        "403":
          description: |
            The caller is not an operator, or the publisher does not exist. ONE answer for both,
            by design — see this operation's description.
          content:
            application/json:
              schema:
                $ref: ./common.schema.json#/$defs/error

  /v1/publishers/{publisherId}/verification:
    get:
      tags: [registry]
      operationId: getPublisherVerificationState
      summary: Read your own publisher's verification state (owner only).
      description: |
        THE F2 TRAP'S ANSWER (S6-C0). `assertPublisherVerified` collapses "does not exist" and
        "exists but unverified" into one error, which is CORRECT for a gate — a gate must not tell
        a stranger which publishers exist. But a developer waiting on approval needs those to be
        different sentences, and a console cannot render a state no endpoint reports.

        So the gate stays collapsed and THIS owner-only path distinguishes them. The asymmetry
        that justifies it: an owner asking about their own publisher learns nothing new, because
        they created it. There is no enumeration risk in telling someone a fact about a row they
        own — which is exactly why this is reachable only as the owner.

        A NON-OWNER RECEIVES `not_authorized`, identical to the answer for a publisher that does
        not exist. The expansion applies to the owner's view alone and to no one else's.
      parameters:
        - name: publisherId
          in: path
          required: true
          schema:
            $ref: ./common.schema.json#/$defs/publisherId
      responses:
        "200":
          description: The owner's view of their publisher's verification state.
          content:
            application/json:
              schema:
                $ref: ./registry.schema.json#/$defs/publisherVerificationState
        "403":
          description: |
            The caller does not own this publisher — including because it does not exist.
            `not_authorized` for both, so existence cannot be probed here.
          content:
            application/json:
              schema:
                $ref: ./common.schema.json#/$defs/error

  /v1/agents/{agentId}/profile:
    get:
      tags: [registry]
      operationId: getPublicAgentProfile
      summary: Read the public profile of an agent you do not own (journey 5).
      description: |
        What a SIGNED-IN NON-OWNER may see before adding an agent. ⚠️ "Public" here means visible
        to any authenticated caller, NOT reachable without a session: this operation declares no
        `security: []` override, so it inherits the global `sessionCookie` requirement (line 66)
        and answers 401 to an anonymous caller. Six operations DO opt out, tersely and visibly;
        this is not one of them. Corrected 2026-08-08 — the prose said "stranger" (which reads as
        unauthenticated) while the schema field said session, and the schema field is what the
        route-guard tests derive from, so the words drifted while every test stayed green.
        Backed by `publicAgentProfile`, a SEPARATE
        definition from `agent` rather than a filtered view of it: `agent` grows fields as the
        platform grows, and a subtractive view would leak each new one by default. A field reaches
        a non-owner only because somebody typed it into that object.

        Reachable only for an agent that is `public` AND whose publisher is verified. FOUR STATES
        ANSWER IDENTICALLY — NOT PUBLIC, UNDER TAKEDOWN, PUBLISHER UNVERIFIED, AND DOES NOT EXIST
        (defensive case 7) — otherwise a stranger could enumerate private agents by watching which
        ids answer differently.

        🔴 THIS SENTENCE LISTED THREE AND OMITTED "PUBLISHER UNVERIFIED", while the `200` response
        below listed four. Corrected 2026-08-06 to the union, and the omission was not cosmetic:
        an implementer building the three would have shipped an endpoint leaking exactly the state
        the other description names, with every test green — because the tests would have been
        written from the same three. Proved by mutation: dropping `AND p.verified_at IS NOT NULL`
        makes an unverified publisher's agent answer `found: true`, and a stranger can then tell
        "publisher unverified" from "does not exist".

        TWO ENUMERATIONS OF ONE SET THAT DISAGREE MEANS NEITHER IS COMPLETE; the instrument is
        their union. Whoever edits either list edits both — they are one fact written twice, and
        that is the standing hazard of saying it twice at all.

        Withheld from a non-owner, each with its reason stated in the schema: `health`,
        `currentVersion` and the manifest, `takenDownAt`, `relationship`, and the publisher's
        owner identity and contact address.
      parameters:
        - name: agentId
          in: path
          required: true
          schema:
            $ref: ./common.schema.json#/$defs/agentId
      responses:
        "200":
          description: |
            The public profile, or `found: false`. An agent that does not exist, is not public,
            whose publisher is unverified, or that is under takedown all answer `found: false`
            with 200 — never a 404, which would be an existence oracle (enforced by
            TestNoCallerScopedPathAnswers404).
          content:
            application/json:
              schema:
                $ref: ./registry.schema.json#/$defs/publicAgentProfileResult

  /v1/publishers:
    get:
      tags: [registry]
      operationId: getMyPublisher
      summary: Read the signed-in user's own publisher.
      description: |
        OWNER-SCOPED BY CONSTRUCTION: this route takes no id, no query parameter and no filter, so a
        caller can only ever receive their own row. That is a stronger guarantee than looking a
        publisher up and then checking ownership — there is no parameter through which somebody
        else's publisher could be named, so the question cannot be asked rather than being asked and
        refused.

        `found: false` with 200 when the caller has not created a publisher yet. That is a normal
        answer to "do I have one", not a failed request — the same convention as
        `GET /v1/auth/session` and `GET /v1/agents/{agentId}`. A 404 here would also be an existence
        oracle by the rule TestNoCallerScopedPathAnswers404 enforces, though with only one possible
        subject it would leak nothing; the envelope is used because it is the house shape for a
        single-entity read, not as a workaround.

        WHY IT EXISTS: `GET /v1/publishers/{publisherId}/verification` (S6-C0's answer to the F2
        trap) needs a `publisherId`, and until this route nothing in the contract could give one to a
        developer who owns a publisher but no agents yet. This is step one of the Dev Console's
        two-step read.
      responses:
        "200":
          description: The caller's publisher, or a not-found envelope if they have none.
          content:
            application/json:
              schema:
                $ref: ./registry.schema.json#/$defs/myPublisherResult
        "401":
          $ref: "#/components/responses/Unauthenticated"

    post:
      operationId: createPublisher
      summary: Register a publisher for the signed-in user
      description: |
        A publisher is the identity agents are published UNDER. Until this endpoint existed, the only
        way to get one was a direct INSERT — Sprint 2's stack had a hand-seeded row, and every
        "register an agent through the real API" claim rested on a publisher that no API had created.

        THE OWNER IS THE AUTHENTICATED CALLER, ALWAYS. There is no ownerUserId field: board #18 was an
        agent registered under someone else's publisher because the claim travelled in the request
        body, and a field the client cannot send is a field the client cannot forge.

        Verification is NOT granted here. `verified` reflects the server's own setting
        (`PUBLISHER_AUTO_VERIFY`, config not contract) — a caller cannot ask to be trusted. Production
        verification is a human decision that gates `public` agents, and it has no endpoint on
        purpose: publishing one would freeze a wire shape for a review process nobody has designed.
      security:
        - sessionCookie: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: ./registry.schema.json#/$defs/createPublisherBody
      responses:
        "201":
          description: The publisher was created and is owned by the caller.
          content:
            application/json:
              schema:
                $ref: ./registry.schema.json#/$defs/publisherResult
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "409":
          description: |
            The caller already owns a personal publisher (`publisher_exists`). A user gets exactly
            one, enforced by an exclusion constraint rather than application logic.

            DECLARED 2026-08-09, and it documents a FIX rather than new behaviour: this case
            previously answered `500 registry_unavailable` with `retryable: true`, telling clients to
            retry a permanent condition forever. Read the existing publisher with `GET /v1/publishers`
            instead of creating another.
          content:
            application/json:
              schema:
                $ref: ./common.schema.json#/$defs/error
        "422":
          $ref: "#/components/responses/SchemaViolation"
        "429":
          $ref: "#/components/responses/RateLimited"

  /v1/agents:
    get:
      tags: [registry]
      operationId: listAgents
      summary: List agents visible to the caller.
      parameters:
        - name: cursor
          in: query
          required: false
          description: Opaque cursor from a previous response's `nextCursor`.
          schema:
            type: string
            maxLength: 512
        - name: limit
          in: query
          required: false
          description: Maximum entries to return. The server enforces its own hard ceiling.
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 25
      responses:
        "200":
          description: A page of agents. `truncated` is always explicit.
          content:
            application/json:
              schema:
                $ref: ./registry.schema.json#/$defs/agentList
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthenticated"
    post:
      tags: [registry]
      operationId: createAgent
      summary: Register a new agent from a manifest.
      description: |
        Idempotent on handle for the same publisher: re-creating an existing handle returns the
        existing agent rather than an error, so a caller that lost its response can retry safely.
        A handle held by a different publisher is a genuine conflict.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: ./registry.schema.json#/$defs/createAgentBody
      responses:
        "201":
          description: Agent registered.
          content:
            application/json:
              schema:
                $ref: ./registry.schema.json#/$defs/agentResult
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "409":
          description: The handle belongs to another publisher.
          content:
            application/json:
              schema:
                $ref: ./common.schema.json#/$defs/error
        "422":
          $ref: "#/components/responses/ManifestInvalid"

  /v1/agents/{agentId}:
    parameters:
      - $ref: "#/components/parameters/AgentId"
    get:
      tags: [registry]
      operationId: getAgent
      summary: Fetch one agent.
      responses:
        "200":
          description: "Agent state. `found: false` when it does not exist or is not visible."
          content:
            application/json:
              schema:
                $ref: ./registry.schema.json#/$defs/agentResult
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthenticated"
    patch:
      tags: [registry]
      operationId: updateAgent
      summary: Publish a new manifest version.
      description: |
        Manifests are versioned rather than overwritten; every update creates a new version, which
        becomes the agent's public changelog. Supply `expectedVersion` to detect a concurrent edit
        instead of silently overwriting one.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: ./registry.schema.json#/$defs/updateAgentBody
      responses:
        "200":
          description: New manifest version published.
          content:
            application/json:
              schema:
                $ref: ./registry.schema.json#/$defs/agentResult
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "409":
          description: |
            `expectedVersion` was stale — another editor published first. Re-read the agent and
            reapply the change rather than retrying blindly.
          content:
            application/json:
              schema:
                $ref: ./common.schema.json#/$defs/error
        "422":
          $ref: "#/components/responses/ManifestInvalid"
    delete:
      operationId: deleteAgent
      summary: Withdraw an agent you own.
      tags: [registry]
      description: |
        OWNER-FACING WITHDRAWAL (DEBT 39). The agent stops appearing anywhere a caller can reach it,
        its credentials are revoked, and its in-flight deliveries are cancelled.

        NOT A MODERATION TAKEDOWN. A takedown is a platform act with an evidence bundle and an
        appeal trail (Architecture.md 7.2, 4.3); this is the owner withdrawing their own agent, and
        the two are recorded separately so a later reader can tell "the platform removed this for
        cause" from "the owner took it down themselves".

        SOFT, NOT DESTRUCTIVE, AND THIS IS OBSERVABLE: conversations that included this agent KEEP
        their full history and still render its past messages with the handle it had at the time.
        A deleted agent's messages are not the owner's to erase — other people spoke in those rooms.
        The handle also stays reserved: it has appeared as an author in other users' history, so it
        is not free to reassign to a different publisher.

        IN-FLIGHT DELIVERIES reach a terminal state rather than continuing to call the agent's
        webhook until their retry schedule runs out. A delivery whose HTTP call is already in
        progress when the delete commits completes that call — the guarantee is the terminal state,
        not instantaneous silence.

        204 ON EVERY OUTCOME A CALLER CAN REACH. An agent that does not exist, one owned by somebody
        else, and one already deleted all answer identically: distinguishing them would let anyone
        iterate ids to learn which agents exist and who owns them, the same existence-oracle rule
        the read paths follow.
      responses:
        "204":
          description: |
            Applied, or nothing to apply. The two are deliberately indistinguishable — see the
            description above.
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "429":
          $ref: "#/components/responses/RateLimited"

  /v1/agents/{agentId}/takedown:
    parameters:
      - $ref: "#/components/parameters/AgentId"
    post:
      tags: [registry]
      operationId: takeDownAgent
      summary: Take an agent down (operator only).
      description: |
        MODERATION'S ACT, NOT THE OWNER'S (DEBT 55). A taken-down agent disappears from every
        surface a caller can reach and stops receiving deliveries. Distinct from
        `DELETE /v1/agents/{agentId}`, which is the OWNER withdrawing their own agent — different
        party, different column, and different reversibility.

        REVERSIBLE, AND THE CASCADE IS DELIBERATELY THIN BECAUSE OF IT. Unlike deletion, a takedown
        does NOT revoke credentials, does NOT sever the agent's conversation memberships, and does
        NOT terminate queued deliveries. Lifting it therefore needs no repair step: the agent's
        rooms, credentials and pending queue are intact and simply become reachable again. Going
        dark is enforced at dispatch — a taken-down agent resolves no webhook endpoint, so in-flight
        deliveries take their ordinary retry path rather than being killed.

        ⚠️ THIS IS NOT A MODERATION PIPELINE. Report intake, evidence bundling and the appeal queue
        (Architecture.md 7.14) are unbuilt. This endpoint lets an operator act and records what they
        said their reason was; it does not record a reviewed case. `reason` is free text for exactly
        that reason.

        OPERATOR ONLY, FAIL-CLOSED: the allow-list is read at boot, so a deployment that never named
        an operator refuses every request here rather than granting the first signed-in caller
        moderation powers.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: ./registry.schema.json#/$defs/takedownBody
      responses:
        "204":
          description: |
            Applied, or nothing to apply — an agent already in the requested state answers
            identically, and so does one that does not exist.
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "403":
          description: |
            The caller is not an operator.

            403 RATHER THAN 404, AND THE REASON IS A RULE THIS CONTRACT ENFORCES ON ITSELF: a 404
            here would have to serve two causes — "you are not an operator" and "no such agent" —
            which separates "does not exist" from "exists but is not yours" for every operator
            probing ids, the existence oracle closed in B2C. This surface refuses by CAPABILITY,
            which is not a fact about any particular agent, so it leaks nothing about which ids are
            real. The agent-level causes stay collapsed inside the 204.
          content:
            application/json:
              schema:
                $ref: ./common.schema.json#/$defs/error
        "422":
          $ref: "#/components/responses/BadRequest"
    delete:
      tags: [registry]
      operationId: restoreAgent
      summary: Lift a takedown (operator only).
      description: |
        Restores an agent taken down by `POST` on this path (DEBT 55). Visibility and delivery
        resume; nothing else needs restoring, because takedown deliberately left credentials,
        memberships and the pending queue untouched.

        A REASON IS REQUIRED HERE TOO. An un-takedown that left no trace would be exactly as bad as
        an untraceable takedown — the same authority acting on the same agent in the opposite
        direction — and an audit that can see a removal but not its reinstatement tells half a
        story. Both directions write their own ledger event.

        A DELETED AGENT CANNOT BE RESTORED BY THIS ROUTE. Deletion is the owner's own irreversible
        act on a different column; clearing a takedown it never had would be a resurrection this
        endpoint does not grant.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: ./registry.schema.json#/$defs/takedownBody
      responses:
        "204":
          description: |
            Applied, or nothing to apply — an agent already in the requested state answers
            identically, and so does one that does not exist.
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "403":
          description: |
            The caller is not an operator.

            403 RATHER THAN 404, AND THE REASON IS A RULE THIS CONTRACT ENFORCES ON ITSELF: a 404
            here would have to serve two causes — "you are not an operator" and "no such agent" —
            which separates "does not exist" from "exists but is not yours" for every operator
            probing ids, the existence oracle closed in B2C. This surface refuses by CAPABILITY,
            which is not a fact about any particular agent, so it leaks nothing about which ids are
            real. The agent-level causes stay collapsed inside the 204.
          content:
            application/json:
              schema:
                $ref: ./common.schema.json#/$defs/error
        "422":
          $ref: "#/components/responses/BadRequest"

  /v1/agents/{agentId}/versions:
    parameters:
      - $ref: "#/components/parameters/AgentId"
    get:
      tags: [registry]
      operationId: listManifestVersions
      summary: List an agent's manifest versions, newest first.
      description: |
        This is the agent's public changelog: users see what changed and when, which is what makes
        a scope escalation visible rather than silent.
      responses:
        "200":
          description: A page of manifest versions.
          content:
            application/json:
              schema:
                $ref: ./registry.schema.json#/$defs/manifestVersionList
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthenticated"

  /v1/agents/{agentId}/access-policy:
    parameters:
      - $ref: "#/components/parameters/AgentId"
    put:
      tags: [registry]
      operationId: setAccessPolicy
      summary: Change who can discover and join the agent.
      description: |
        Friction is proportional to reach. Moving to `public` requires a verified publisher and a
        category label; the platform rejects the transition rather than silently downgrading it.
        There is deliberately no quality-review stage — quality is the trust ledger's job.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: ./registry.schema.json#/$defs/accessPolicyTransitionBody
      responses:
        "200":
          description: Policy applied.
          content:
            application/json:
              schema:
                $ref: ./registry.schema.json#/$defs/agentResult
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "403":
          description: |
            The transition is not permitted yet — for example `public` without publisher
            verification or without a category label.
          content:
            application/json:
              schema:
                $ref: ./common.schema.json#/$defs/error

  /v1/agents/{agentId}/members:
    parameters:
      - $ref: "#/components/parameters/AgentId"
    post:
      tags: [registry]
      operationId: createMembership
      summary: Add this agent, consenting to the scopes named in the body.
      description: |
        CONTRACT ONLY THIS TASK (S4-C0) — S4-B3 implements the handler. The reader
        (`GetMembership`) already exists; this names its writer (board #20/#81, measured: 1 reader,
        0 production writers before this task).

        IDEMPOTENT ON (user, agent): calling this again for an existing membership updates
        `grantedScopes`/`consentedVersionId` and returns 200 with the refreshed row — the same
        re-consent flow SPEC:25-30 describes, run again after a manifest update added scopes. This
        is the SAME operation for a first add and a re-consent; there is no separate endpoint for
        each.

        `access_policy` GATES WHETHER THIS CALL SUCCEEDS AT ALL, but the gate is not this
        endpoint's to enforce a second time in the wire shape: `public` adds instantly (this call
        succeeds outright); `request` and `invite` mean the caller is not yet a member in the
        sense this endpoint completes — S4-B3's implementation is where that distinction is
        actually drawn (S3 #81 ruling; the accept/invite halves are S6/S7 and out of THIS task's
        scope). This operation's contract is deliberately silent on which policy produced success,
        because the caller does not need to know — it needs to know whether it is now a member.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: ./registry.schema.json#/$defs/createMembershipBody
      responses:
        "200":
          description: |
            Membership created or refreshed. Always 200, never 201 — a repeat call with the same or
            different scopes is a normal re-consent, not an error, so there is no "already exists"
            failure mode to distinguish from first creation.
          content:
            application/json:
              schema:
                $ref: ./registry.schema.json#/$defs/membershipResult
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "403":
          description: |
            The agent does not exist, or is not visible to the caller (private/invite-only, no
            matching invite). 403 rather than 404: this is a WRITE on a caller-scoped path, and a
            404 here would separate "no such agent" from "an agent exists but you cannot join it"
            — the existence oracle closed for reads in B2C applies to this write the same way.
          content:
            application/json:
              schema:
                $ref: ./common.schema.json#/$defs/error
        "422":
          description: A granted scope is not in the agent's current `requested_scopes` — `scope_not_requested`.
          content:
            application/json:
              schema:
                $ref: ./common.schema.json#/$defs/error

  /v1/agents/{agentId}/members/count:
    parameters:
      - $ref: "#/components/parameters/AgentId"
    get:
      tags: [registry]
      operationId: getAgentMemberCount
      summary: How many users have added this agent — the Dev Console's USERS column.
      description: |
        DEV-TIER, publisher-owner only. A count of `registry.agent_member` rows for one agent.

        🔴 ITS OWN ROUTE RATHER THAN A FIELD ON `getAgent`, AND THAT IS A DISCLOSURE DECISION, NOT A
        STYLISTIC ONE. `GET /v1/agents/{agentId}` is visible to the owner, to any existing member,
        AND to any signed-in caller when the agent is `public`. Carrying an adoption count on that
        object would publish it to every stranger who can see the agent at all — a new disclosure on
        a route that has none today, and one no existing test would have caught, because the route's
        authorization would remain perfectly correct for everything else it returns. An owner-scoped
        route keeps the audience exactly one publisher wide, which is the audience that asked.

        `memberCount` IS SENT AT ZERO rather than omitted: a count over an empty set is a
        measurement ("nobody has added this agent"), not an undefined quantity. The omit-never-zero
        discipline on the metrics surface governs rates over empty denominators, a different case.

        An agent you do not own reads as `found:false`, not 403 — "no such agent" and "not yours"
        are ONE answer, matching every other agent-scoped read on this platform.
      responses:
        "200":
          description: |
            The agent's member count, or a not-found envelope. `found:false` covers both a
            nonexistent agent and one the caller does not own — deliberately indistinguishable.
          content:
            application/json:
              schema:
                $ref: ./registry.schema.json#/$defs/agentMemberCountResult
        "401":
          $ref: "#/components/responses/Unauthenticated"

  /v1/agents/{agentId}/members/me:
    parameters:
      - $ref: "#/components/parameters/AgentId"
    get:
      tags: [registry]
      operationId: getMyMembership
      summary: Read the caller's own membership of this agent — what scopes are actually granted.
      description: |
        DEBT 59 / LANE A12. The read half of `createMembership`/`leaveAgent`: before this route,
        the only way to learn `grantedScopes` or `newScopesSinceConsent` was to call the WRITE verb
        and read its response — and that POST overwrites `grantedScopes` with whatever the caller
        sends, so using it as a read meant risking a silent, unintended re-consent. `Store.
        GetMembership` (consent.go) already computed the correct answer, with zero HTTP caller,
        before this route existed.

        `found: false` with 200 for EVERY cause the caller cannot act on differently: never joined,
        no such agent, or an agent that exists but is not visible to this caller. Unlike
        `GET /v1/agents/{agentId}`, this row is keyed on (agentId, callerUserId) — there is no
        "exists but is not yours" case distinct from "does not exist" to collapse, because the
        caller can only ever be asking about their OWN membership. Same discipline `leaveAgent`
        already documents for the identical key shape.
      responses:
        "200":
          description: |
            The caller's membership, if any. `found: false` when the caller has never joined, the
            agent does not exist, or the agent is not visible to the caller — all indistinguishable
            (existence-oracle rule, matching `leaveAgent` above).
          content:
            application/json:
              schema:
                $ref: ./registry.schema.json#/$defs/membershipLookupResult
        "401":
          $ref: "#/components/responses/Unauthenticated"
    delete:
      tags: [registry]
      operationId: leaveAgent
      summary: Remove this agent from the caller's own fleet — "I added the wrong agent."
      description: |
        DECISIONS.md S15-T0, decision 3. `leaveAgent`, NOT `deleteAgent` — the dispatch that
        requested this route named the case it fixes as "a user who adds the wrong agent", which
        is the CALLER undoing their own `createMembership` row, never destruction of the agent
        itself. `DELETE /v1/agents/{agentId}` (removing an agent for every OTHER member and its
        publisher too) is a different, far more consequential operation this route deliberately
        does not provide and was never asked for — conflating the two verbs was checked against the
        dispatch's own wording before this route was named.

        Deletes the caller's OWN `registry.agent_member` row and nothing else: no other member's
        membership, no conversation, no message history, no ledger event is rewritten. The agent
        itself, its publisher, and every other member's relationship to it are unaffected.

        ALWAYS 204 (same existence-oracle shape as `revokeCredential` above): "not a member",
        "no such agent", and "already left" are the identical response. A caller who never joined
        gets 204 for leaving nothing — there is no legitimate remedy a distinguishing status would
        enable, and a 404 here would let a caller probe which agent ids exist by which status they
        get back.

        IDEMPOTENT: leaving twice is 204 both times.

        `relationship` FLIPS ON THE NEXT READ: a caller who leaves and then calls `GET /v1/agents`
        sees the agent (if still visible at all — a `public` agent stays visible as `relationship:
        "public"`; a `private`/`invite`/`request` agent the caller can no longer see disappears
        from the list entirely, same as `visibleTo`'s collapse everywhere else in this contract)
        rather than `member`. Re-joining a `public` agent after leaving is `createMembership` again
        — this route does not need to express re-add, because that endpoint already is idempotent
        and already handles a first add and a re-consent identically.
      responses:
        "204":
          description: |
            The caller is no longer a member, OR was never one, OR no such agent exists. Every
            cause is this one response (existence-oracle rule, same as `revokeCredential`).
        "401":
          $ref: "#/components/responses/Unauthenticated"

  /v1/agents/{agentId}/credentials:
    parameters:
      - $ref: "#/components/parameters/AgentId"
    post:
      tags: [registry]
      operationId: issueCredential
      summary: Issue or rotate the agent's HMAC signing credential.
      description: |
        The secret is returned EXACTLY ONCE, in this response, and by no other endpoint. Store it
        immediately; the platform holds it wrapped and cannot show it again.

        Rotation overlaps by default: `retireCurrentAfter` keeps the outgoing secret verifying
        until the given instant, so deliveries signed moments before the rotation are not dropped.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: ./registry.schema.json#/$defs/issueCredentialBody
      responses:
        "201":
          description: Credential issued. Contains the only copy of the secret you will receive.
          content:
            application/json:
              schema:
                $ref: ./registry.schema.json#/$defs/credential
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "409":
          description: The agent already has the maximum number of concurrently valid credentials.
          content:
            application/json:
              schema:
                $ref: ./common.schema.json#/$defs/error
    get:
      tags: [registry]
      operationId: listCredentials
      summary: List an agent's credentials — never their secrets.
      description: |
        NEVER 404 (S12 T1, existence-oracle rule B2C — see B2C's own history: closed once on the
        registry list path, reopened one surface over on conversation sub-resources, now enforced by
        a structural test with no exemption list). An agent that does not exist and one the caller
        owns but has issued zero credentials for answer the SAME empty page,
        `{items:[],count:0,total:0,truncated:false}`. Ownership is IN the query, mirroring
        `GET /v1/agents/{agentId}/deliveries`'s own read-hide behaviour exactly.

        For an OWNED agent, live, retired and revoked credentials are ALL included, newest first: an
        owner checking whether they already killed a leaked credential must see that they did, not an
        emptied list indistinguishable from "nothing was ever issued".

        THE SECRET NEVER APPEARS HERE. It is returned exactly once, by `POST` on this same path, and
        by no other endpoint — see that operation's own description.
      responses:
        "200":
          description: |
            The agent's credentials — or an empty page. An empty page means EITHER the caller does
            not own this agent (including because it does not exist) OR they own it and have issued
            none; the two are indistinguishable by design.
          content:
            application/json:
              schema:
                $ref: ./registry.schema.json#/$defs/credentialList
        "401":
          $ref: "#/components/responses/Unauthenticated"

  /v1/agents/{agentId}/credentials/{credentialId}:
    parameters:
      - $ref: "#/components/parameters/AgentId"
      - name: credentialId
        in: path
        required: true
        description: Identifier of the credential, prefixed `cred_`.
        schema:
          type: string
          pattern: ^cred_[0-9A-HJKMNP-TV-Z]{26}$
    delete:
      tags: [registry]
      operationId: revokeCredential
      summary: Retire one credential explicitly, with no replacement.
      description: |
        ALWAYS 204 (S12 T1, same existence-oracle rule as the list operation above, applied to a
        write). "Not yours", "no such agent", "no such credential on this agent" and "already
        retired" are ALL the same response — a DELETE's entire observable is its status code, so even
        a narrower split (say, 403 for not-yours) would still be an oracle one HTTP verb over. There
        is no legitimate remedy a caller could act on that a distinguishing status would enable.

        IDEMPOTENT: revoking an already-retired credential is also 204, never an error — a caller
        that double-clicks or retries after a dropped response must not be told something broke.

        DELETING THE LAST VALID CREDENTIAL IS ALLOWED. The platform does not refuse to protect an
        owner from their own explicit action; the agent's next delivery then fails loudly (visible in
        the delivery log) rather than the platform silently keeping a distrusted secret alive.
      responses:
        "204":
          description: |
            The credential no longer verifies, OR it was already retired, OR the caller does not own
            it, OR no such credential/agent exists. Every cause is this one response.
        "401":
          $ref: "#/components/responses/Unauthenticated"

  /v1/conversations:
    get:
      tags: [conversation]
      operationId: listConversations
      summary: List conversations visible to the signed-in caller.
      description: |
        HUMAN-FACING — `camelCase`, session cookie. The caller is the session user; there is no
        parameter naming whose conversations to list, because that would be a claim rather than a
        reference (the shape that produced the registry squatting hole, B2D).

        `total` counts only what the caller may see. A total computed over all rows would disclose
        how many conversations exist — the existence oracle closed in the registry list path (B2C).
      parameters:
        - name: cursor
          in: query
          required: false
          schema: { type: string, maxLength: 512 }
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
      responses:
        "200":
          description: A page of conversations.
          content:
            application/json:
              schema:
                $ref: ./conversation.schema.json#/$defs/conversationList
        "401":
          $ref: "#/components/responses/Unauthenticated"
    post:
      tags: [conversation]
      operationId: createConversation
      summary: Start a conversation with one or more agents, and/or one or more human contacts.
      description: |
        HUMAN-FACING — `camelCase`, session cookie. The human participant is the session user and
        is never named in the body. `participantUserIds` (M2.5, widened from a single id to a list
        the same sprint group rooms shipped) names OTHER people to seat — every one of them must
        already be a `conversation.user_contact` of the caller, established by redeeming an invite
        token first (`POST /v1/contacts/invites/redeem`). Two or more total participants besides the
        caller (agents and/or humans combined) makes a `group` room; exactly one makes `direct`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: ./conversation.schema.json#/$defs/createConversationBody
      responses:
        "201":
          description: |
            Conversation created. Returns the conversation DIRECTLY, not the `found`/`item` lookup
            envelope: on a 201 the server has just created the row, so `found` could only ever be
            `true`, and a client branching on it writes a branch for a state that cannot occur
            (board #85). `conversationResult` remains correct for GET, where absence is a real
            answer that must stay indistinguishable from inaccessibility.
          content:
            application/json:
              schema:
                $ref: ./conversation.schema.json#/$defs/conversationCreated
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "422":
          description: |
            An agent id does not exist or is not one this caller may converse with, OR
            `participantUserIds` names someone who is not (yet) a contact of the caller. The WHOLE
            request is refused if ANY one id fails either check — no partial room.
          content:
            application/json:
              schema:
                $ref: ./common.schema.json#/$defs/error

  /v1/contacts:
    get:
      tags: [conversation]
      operationId: listContacts
      summary: The caller's human contact graph.
      description: |
        M2.5 (LADDER.md) — the READ half of a WRITE that shipped without one: redeeming an invite
        token (`POST /v1/contacts/invites/redeem`) populates `conversation.user_contact`, and until
        this endpoint nothing could read it back — the exact "table has a writer, nobody can read
        it" defect shape this project keeps finding. Used to populate the people-picker for
        `createConversationBody.participantUserIds` / `addMembersBody.userIds`, both of which
        require every named id to already be a contact.

        NO DISPLAY NAME. See `contact`'s own schema description for why — the same reason
        `conversationUserMember` carries none.
      parameters:
        - name: cursor
          in: query
          required: false
          schema: { type: string, maxLength: 512 }
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
      responses:
        "200":
          description: A page of the caller's contacts.
          content:
            application/json:
              schema:
                $ref: ./conversation.schema.json#/$defs/contactList
        "401":
          $ref: "#/components/responses/Unauthenticated"

  /v1/contacts/invites:
    post:
      tags: [conversation]
      operationId: mintContactInvite
      summary: Mint a one-time invite token for the human contact graph.
      description: |
        M2.5 (LADDER.md "nguoi that thu hai"). identity has no public username to type, so this is
        the only way to add a second real person today: mint a token here, hand it to them out of
        band (a link, an email, a message in some other app), and they redeem it at
        `POST /v1/contacts/invites/redeem`.

        🔴 THE SECRET APPEARS EXACTLY ONCE, IN THIS RESPONSE. Same contract `mintCliToken` publishes
        for CLI tokens: the token is hashed at rest (migration 00024), so there is no path back to
        the plaintext. Lost it? Mint another; the old one still expires on its own schedule.

        LIFETIME IS SERVER-CHOSEN (`INVITE_TOKEN_TTL_DAYS`, config, not a request field) — see
        `inviteMintResult.expiresAt` for why this is config-not-wire rather than a per-request
        parameter.
      security:
        - sessionCookie: []
      responses:
        "201":
          description: Token minted. Contains the only copy of the secret you will receive.
          content:
            application/json:
              schema:
                $ref: ./conversation.schema.json#/$defs/inviteMintResult
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "429":
          $ref: "#/components/responses/RateLimited"

  /v1/contacts/invites/redeem:
    post:
      tags: [conversation]
      operationId: redeemContactInvite
      summary: Redeem an invite token, becoming a mutual contact of the inviter.
      description: |
        M2.5 (LADDER.md). Redeeming establishes a TWO-WAY `conversation.user_contact` edge (both
        `(inviter, redeemer)` and `(redeemer, inviter)` rows — LADDER's own measurable is that a
        query for either direction returns exactly one row, never zero, never three) and returns a
        direct room the two can message in immediately.

        ONE USE PER TOKEN. A second redemption of the same token — by anyone, including the first
        redeemer replaying the request — fails the SAME way an unknown or expired token does: this
        endpoint answers one collapsed refusal for "no such token" / "expired" / "already redeemed"
        / "that's your own token", matching `revokeCliToken`'s and every other caller-scoped lookup
        in this contract's "not found and not yours read identically" discipline (B2). Distinguishing
        the causes would let a caller learn things about a token it does not hold — which invite
        links are real, which have already been used by someone else, and so on.
      security:
        - sessionCookie: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: ./conversation.schema.json#/$defs/redeemInviteBody
      responses:
        "200":
          description: Redeemed. The caller and the inviter are now mutual contacts.
          content:
            application/json:
              schema:
                $ref: ./conversation.schema.json#/$defs/redeemInviteResult
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "403":
          description: |
            The token does not exist, has expired, has already been redeemed, or names the caller's
            own invite (self-redeem). One answer for all four causes (B2, TestNoCallerScopedPathAnswers404)
            — a write path answers 403 rather than 404 so the status code itself cannot be used to
            probe which token values are real. See this operation's own description for why the
            causes are not distinguished.
          content:
            application/json:
              schema:
                $ref: ./common.schema.json#/$defs/error
        "429":
          $ref: "#/components/responses/RateLimited"

  /v1/conversations/{conversationId}:
    parameters:
      - name: conversationId
        in: path
        required: true
        schema: { type: string, pattern: "^cnv_[0-9A-HJKMNP-TV-Z]{26}$" }
    get:
      tags: [conversation]
      operationId: getConversation
      summary: Read one conversation.
      description: |
        A conversation that does not exist, and one the caller may not see, answer IDENTICALLY
        with `found: false` and 200 — so the response cannot be used to test which rooms exist.
      responses:
        "200":
          description: Present or absent; absence is an answer, not an error.
          content:
            application/json:
              schema:
                $ref: ./conversation.schema.json#/$defs/conversationResult
        "401":
          $ref: "#/components/responses/Unauthenticated"

  /v1/conversations/{conversationId}/messages:
    parameters:
      - name: conversationId
        in: path
        required: true
        schema: { type: string, pattern: "^cnv_[0-9A-HJKMNP-TV-Z]{26}$" }
    get:
      tags: [conversation]
      operationId: listMessages
      summary: Read a page of messages, oldest first.
      parameters:
        - name: cursor
          in: query
          required: false
          schema: { type: string, maxLength: 512 }
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 50 }
      responses:
        "200":
          description: |
            A page of messages the caller may see.

            NO 404 ON THIS PATH, DELIBERATELY. A conversation that does not exist, and one that
            exists but is invisible to the caller, both return an EMPTY page (`count`/`total` 0) —
            not an error. A 404 here would answer "this room exists but is not yours", which is
            precisely the existence oracle closed in the registry list path (B2C); the sibling
            sub-resource read `GET /v1/agents/{agentId}/versions` declares no 404 for the same
            reason. Absence and inaccessibility must be one answer, and the empty page is it.

            THE RULE, stated once so later endpoints inherit it rather than re-derive it: a read
            surface must never let a caller distinguish "does not exist" from "exists and is not
            yours". Whichever answer is cheaper to give — empty list, `found: false`, 200 — give the
            SAME one to both. Every read added since has followed this: agent versions, the delivery
            log added in S3-C0, and the single-item lookups through `getEnvelope`.
          content:
            application/json:
              schema:
                $ref: ./conversation.schema.json#/$defs/messageList
        "401":
          $ref: "#/components/responses/Unauthenticated"
    post:
      tags: [conversation]
      operationId: sendMessage
      summary: Post a human message into a conversation.
      description: |
        Text only. Blocks are what AGENTS produce; accepting them here would let a browser inject
        renderer payloads the block vocabulary exists to constrain. The author is the session user.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: ./conversation.schema.json#/$defs/sendMessageBody
      responses:
        "201":
          description: Message stored and fanned out.
          content:
            application/json:
              schema:
                $ref: ./conversation.schema.json#/$defs/message
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "403":
          description: |
            The caller may not post into this conversation — because it does not exist, or exists
            and is not theirs. ONE ANSWER FOR BOTH, and 403 rather than 404 to match how the
            registry answers a write against an invisible resource
            (`PUT /v1/agents/{agentId}/access-policy`). A 404 would separate the two cases and
            turn this write into an existence probe for rooms the caller cannot read.
          content:
            application/json:
              schema:
                $ref: ./common.schema.json#/$defs/error
        "429":
          $ref: "#/components/responses/RateLimited"

  /v1/conversations/{conversationId}/messages/{messageId}:
    parameters:
      - name: conversationId
        in: path
        required: true
        schema: { type: string, pattern: "^cnv_[0-9A-HJKMNP-TV-Z]{26}$" }
      - name: messageId
        in: path
        required: true
        schema: { type: string, pattern: "^msg_[0-9A-HJKMNP-TV-Z]{26}$" }
    delete:
      tags: [conversation]
      operationId: redactMessage
      summary: Redact a message — "I sent the wrong thing."
      description: |
        DECISIONS.md S15-T0, decision 3. TOMBSTONE-REDACTION, not deletion: `redacted_at` is
        stamped and `body`/`blocks` are cleared in the same statement, matching the mechanism
        `conversation.message` was built with (migration 00003:114-125) and the crypto-shredding
        seam it already reserves for the real erasure mechanism this platform does not yet have
        (`payload_key_id`, unused until a KMS exists). The row and the ledger hash chain survive
        unchanged — this is the same choice `left_at`/DEBT 26 makes for departure, stated here
        because this is the door nobody had built for it, not because the shape was undecided.

        AUTHOR-ONLY. Only the message's own `authorType: "user"` / `authorId` may redact it — never
        another participant, never the conversation's other members, and an agent's own messages
        are not redactable through this human-facing route at all (agents have no session to call
        it with; a redaction path for agent-authored content is a separate, unbuilt question this
        route does not answer).

        🔴 **WHAT AN ALREADY-DELIVERED AGENT SEES, STATED PLAINLY BECAUSE IT CANNOT BE UN-PUBLISHED
        ONCE AN AGENT DEPENDS ON THE ANSWER:** an agent that already received this message via
        `message.created` (delivery.schema.json) keeps whatever it stored from that delivery. THIS
        PLATFORM SENDS NO RETRACTION. `delivery.schema.json`'s `event` enum is closed
        (`message.created`, `task.assigned`, `task.cancelled`, `approval.resolved`,
        `tick.scheduled`, `member.joined`, `tool.result`) and has no `message.redacted` member — an
        agent cannot un-know a message it was delivered, and this contract does not pretend
        otherwise by inventing a notification the platform does not send. The only thing redaction
        guarantees is that FUTURE reads of this conversation (by any caller, human or agent, via
        `GET /v1/conversations/{conversationId}/messages` or any future agent-facing message read)
        see the tombstone, never the original content. A `message.redacted` delivery event is
        future scope, not silently assumed here.

        IDEMPOTENT: redacting an already-redacted message is 204, never an error.
      responses:
        "204":
          description: |
            The message is now redacted, OR was already redacted, OR the caller is not its author,
            OR no such message/conversation exists. ONE ANSWER FOR ALL FOUR (existence-oracle rule,
            same shape as `revokeCredential`/`leaveAgent` above) — a distinguishing status here
            would let a caller probe which message ids exist in a conversation they cannot read,
            or learn whether a message they can see was authored by someone specific.
        "401":
          $ref: "#/components/responses/Unauthenticated"

  /v1/conversations/{conversationId}/messages/{messageId}/assets/{assetId}/url:
    parameters:
      - name: conversationId
        in: path
        required: true
        schema: { type: string, pattern: "^cnv_[0-9A-HJKMNP-TV-Z]{26}$" }
      - name: messageId
        in: path
        required: true
        schema: { type: string, pattern: "^msg_[0-9A-HJKMNP-TV-Z]{26}$" }
      - name: assetId
        in: path
        required: true
        schema: { type: string, pattern: "^ast_[0-9A-HJKMNP-TV-Z]{26}$" }
    get:
      tags: [conversation]
      operationId: getMessageAssetURL
      summary: Mint a signed download URL for an asset referenced in a message the caller can read.
      description: |
        HUMAN-FACING — `camelCase`, session cookie (S13-T1, DEBT 22).

        THE DEFECT THIS CLOSES: `GET /v1/assets/{assetId}/url` scopes to assets the CALLER owns
        (`asset.schema.json`'s `downloadURLResultHuman`, unchanged by this operation). An agent that
        uploads an asset and sends it in an `image`/`file`/`audio`/`card` block owns that asset; the
        human it was sent to never does. So the caller-owned route can never resolve it for the
        recipient — `found:false`, permanently, for a message the recipient can otherwise read in
        full. This route answers "can THIS PARTICIPANT see THIS asset, as referenced in THIS
        message" instead of "does the caller own this asset".

        NEVER 404, same existence-oracle rule as every other caller-scoped read (B2C). `found:false`
        covers THREE causes, deliberately collapsed into one answer:
          - the caller is not a current participant of conversationId
          - messageId does not belong to conversationId, or does not exist
          - assetId is not referenced by ANY block in that specific message (not merely any asset
            the caller can name — a participant proves nothing about an unrelated asset id by
            asking about it here, because the check is scoped to one message's own content)

        A caller able to distinguish these would learn whether a conversation/message/asset id
        exists without being entitled to see it — the same closure `GET /v1/assets/{assetId}/url`
        already applies to ownership, applied here to participation and block-membership instead.

        Uses `downloadURLResultHuman` — the identical wire shape `GET /v1/assets/{assetId}/url`
        already publishes, not a new schema, because the RESPONSE a caller receives is the same
        kind of thing (a signed URL or `found:false`); only the AUTHORIZATION QUESTION differs.
      responses:
        "200":
          description: "`found:true` with a fresh signed download URL, or `found:false` — see description."
          content:
            application/json:
              schema:
                $ref: ./asset.schema.json#/$defs/downloadURLResultHuman
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "503":
          description: Object storage is not configured on this deployment.
          content:
            application/json:
              schema:
                $ref: ./common.schema.json#/$defs/error

  /v1/conversations/{conversationId}/participation:
    parameters:
      - name: conversationId
        in: path
        required: true
        schema: { type: string, pattern: "^cnv_[0-9A-HJKMNP-TV-Z]{26}$" }
    patch:
      tags: [conversation]
      operationId: setParticipation
      summary: Set your own pin / archive / mute flags for a conversation.
      description: |
        THE CALLER'S OWN FLAGS, never anyone else's (DEBT 60). These are per-participant columns:
        one member pinning a room must not reorder anyone else's inbox, so there is no parameter
        naming whose flags to set — the subject is the session.

        PATCH SEMANTICS ARE THE CONTRACT: an ABSENT key preserves that flag, an EXPLICIT NULL
        clears it. A client toggling one flag sends only that flag and cannot clobber the other two,
        which is what makes concurrent tabs safe. Setting a flag sends `true`; the SERVER stamps the
        timestamp, because a caller-supplied one would be a caller-chosen sort key.

        RE-PINNING AN ALREADY-PINNED ROOM DOES NOT MOVE IT. `pinnedAt` orders the Pinned section, so
        the first-pin moment is kept as a stable sort key rather than refreshed on every toggle.

        `archived` IS NOT `state: "archived"`. This files the room out of the CALLER's own inbox;
        that enum archives the room for everyone. Archived rooms are still returned by the list
        route — a client sections them itself, because filtering them server-side would make
        `total`/`truncated`/`nextCursor` describe a different set than the page.

        ⚠️ `muted` RECORDS A PREFERENCE AND SUPPRESSES NOTHING TODAY. Nothing consumes the flag yet
        (DEBT 70 owns the suppression consumer); see `conversation.mutedAt` for the full statement.
        A client must not present it as though it silenced anything.

        204 ON EVERY OUTCOME A CALLER CAN REACH. A conversation that does not exist, and one the
        caller does not participate in, answer identically — the same existence-oracle rule the
        read paths follow. Re-read the conversation to see the applied state.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: ./conversation.schema.json#/$defs/participationBody
      responses:
        "204":
          description: |
            Applied, or nothing to apply. The two are deliberately indistinguishable.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthenticated"

  /v1/conversations/{conversationId}/read-cursor:
    parameters:
      - name: conversationId
        in: path
        required: true
        schema: { type: string, pattern: "^cnv_[0-9A-HJKMNP-TV-Z]{26}$" }
    put:
      tags: [conversation]
      operationId: setReadCursor
      summary: Advance the caller's read position.
      description: |
        The actor is the session user. The server refuses to move a cursor BACKWARD, so a stale
        client cannot un-read messages for a user reading on another device.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: ./conversation.schema.json#/$defs/readCursorBody
      responses:
        "204":
          description: Cursor advanced, or already at or past that message.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "403":
          description: |
            The caller may not move a cursor in this conversation — because it does not exist, or
            exists and is not theirs. Same single answer, and the same 403-not-404 rule as
            `POST /v1/conversations/{conversationId}/messages`.
          content:
            application/json:
              schema:
                $ref: ./common.schema.json#/$defs/error

  /v1/conversations/{conversationId}/members:
    parameters:
      - name: conversationId
        in: path
        required: true
        schema: { type: string, pattern: "^cnv_[0-9A-HJKMNP-TV-Z]{26}$" }
    post:
      tags: [conversation]
      operationId: addConversationMembers
      summary: Seat new agents and/or humans into an existing GROUP room.
      description: |
        M2.5 (LADDER.md "group co ban"). The caller must already be a participant. GROUP ROOMS
        ONLY — see `addMembersBody`'s own description for why a `direct` room refuses this rather
        than silently becoming a group. Every `userId` must already be a contact of the CALLER.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: ./conversation.schema.json#/$defs/addMembersBody
      responses:
        "200":
          description: |
            Members added (or already present — idempotent). Returns the conversation DIRECTLY,
            with its refreshed agentMembers/userMembers, matching conversationCreated's own
            "the call just wrote the row, so there is nothing to look up" reasoning.
          content:
            application/json:
              schema:
                $ref: ./conversation.schema.json#/$defs/conversation
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "403":
          description: |
            The caller may not add members to this conversation — because it does not exist, or
            exists and they are not a participant. Same single answer, same 403-not-404 rule as
            every other caller-scoped write on this surface.
          content:
            application/json:
              schema:
                $ref: ./common.schema.json#/$defs/error
        "422":
          description: |
            The room is `direct`, not `group` (`group_membership_requires_group`); OR an agentId
            does not exist or is not conversable by the caller; OR a userId is not (yet) a contact
            of the caller. The WHOLE request is refused if ANY one id fails its check.
          content:
            application/json:
              schema:
                $ref: ./common.schema.json#/$defs/error

  /v1/conversations/{conversationId}/members/me:
    parameters:
      - name: conversationId
        in: path
        required: true
        schema: { type: string, pattern: "^cnv_[0-9A-HJKMNP-TV-Z]{26}$" }
    delete:
      tags: [conversation]
      operationId: leaveConversation
      summary: Leave a group conversation.
      description: |
        M2.5 (LADDER.md). Mirrors `DELETE /v1/agents/{agentId}/members/me` exactly: ALWAYS
        SUCCEEDS, every cause (no such conversation, never a participant, already left) collapses
        into the same 204 — there is no legitimate remedy the caller could act on differently for
        any of them. GROUP ROOMS ONLY: leaving a `direct` room is refused (`422
        group_membership_requires_group`) — a direct room has no "the room continues without you"
        state, so there is nothing to leave INTO.

        THE ROW SURVIVES, MARKED left_at. Past messages stay attributable to a member who has since
        left (`conversation.conversation_participant.left_at`, migration 00003:67) — this is not a
        DELETE.
      security:
        - sessionCookie: []
      responses:
        "204":
          description: Left, or was never a participant, or had already left — all the same outcome.
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "422":
          description: The room is `direct`, not `group` — see this operation's own description.
          content:
            application/json:
              schema:
                $ref: ./common.schema.json#/$defs/error

  /v1/events:
    get:
      tags: [conversation]
      operationId: streamEvents
      summary: Server-Sent Events stream for the signed-in caller.
      description: |
        HUMAN-FACING — `camelCase` event payloads, session cookie, same credentialed allow-list as
        every other browser endpoint (signed SSE ruling). There is NO parameter selecting whose
        stream to open: the subject is derived from the session, because a client-supplied id would
        let any signed-in caller subscribe to another person's events.

        WIRE FRAMING — every event is a NAMED SSE event. Each frame is
        `id: <evt_N>\nevent: <type>\ndata: <json>\n\n`, where `<type>` is the `eventType` value.

        A browser MUST therefore register `addEventListener("<type>", …)` per type.
        `EventSource.onmessage` fires ONLY for events with NO `event:` field and will never see these
        frames — board #62 was exactly that: the server framed named events, the client listened on
        `onmessage`, the connection reported healthy, and not one event was ever delivered. The
        console stayed clean because a listener that was never registered cannot complain. Naming
        the framing here gives that divergence an arbiter it did not have.

        RECONNECT RECOVERY IS REFETCH, AND REFETCH IS THE MECHANISM — not a fallback for a rare
        server-side flush. A client MUST refetch the affected view on EVERY `streamReady` (sent once
        per connection open, so it recurs on every reconnect) and MUST NOT assume continuity across
        a reconnect.

        THERE IS NO RESUME. The server does not read `Last-Event-ID`; no replay-from-cursor path
        exists. This endpoint previously documented such a header and the platform never implemented
        it, so every reconnect was a full gap while the contract implied a rare one — the promise was
        removed rather than the gap hidden. `id:` frames are still emitted (native `EventSource`
        sends the header back regardless of what is documented), but the server ignores them on
        arrival: they order events within one connection, they do not resume across connections.

        WHEN THIS CHANGES — real resume gets built when EITHER trigger fires, and not before:
        (1) a SECOND REPLICA is provisioned, which is when the in-process broker must become a bus
        anyway (DEBT 11, DECISIONS T4 foundation #1 — LISTEN/NOTIFY as a WAKE SIGNAL plus a
        caller-scoped refetch, never a durable SSE data channel); or (2) reconnect-refetch cost
        becomes measurable against the reopen experience. Any such work must first fix the event-id
        scheme (DEBT 67): ids are process-global and restart from `evt_1`, so a cursor is ambiguous
        across a deploy, not merely unresolvable.
      responses:
        "200":
          description: |
            An event stream. Each `data:` frame is one StreamEvent.
          content:
            text/event-stream:
              schema:
                $ref: ./sse.schema.json#/$defs/event
        "401":
          $ref: "#/components/responses/Unauthenticated"

  /hooks/v1/reply:
    post:
      tags: [hooks]
      operationId: postReply
      summary: Deliver an agent's answer into a conversation.
      security:
        - agentSignature: []
      description: |
        AGENT-FACING — `snake_case` body, HMAC authentication, no session cookie.

        Sign the request as `t=<unix>,v1=hex(hmac_sha256(secret, t + "." + raw_body))` over the
        RAW bytes, and send within ±5 minutes of that timestamp. Send `Idempotency-Key` so a retry
        is collapsed rather than posted twice.

        Blocks are validated as a unit: if any block is invalid the whole reply is rejected. That
        is a security property — partial rendering would let one crafted block suppress the rest.
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          description: |
            The delivery id being answered, or an agent-chosen key for a proactive message.
            Reused unchanged on every retry.
          schema:
            type: string
            minLength: 8
            maxLength: 128
        - name: X-Platform-Signature
          in: header
          required: true
          description: 't=<unix>,v1=<hex hmac-sha256 over "<t>.<raw body>">'
          schema:
            type: string
        - name: Authorization
          in: header
          required: true
          description: |
            `Authorization: HMAC <agent_id>/<...>` — the agent-facing credential header, published
            for the WHOLE `/hooks/v1/*` surface (Architecture.md:383, alongside `Idempotency-Key`).

            IT SERVES TWO INDEPENDENT PURPOSES. Both are load-bearing, and the second outlives any
            change to the first:

            1. HMAC KEY RESOLUTION. `X-Platform-Signature` carries no key identifier, so without an
               agent id the platform would have to try every credential it holds to verify one
               request. This bounds try-all to a single agent's currently-valid credentials —
               normally one, and two only during a rotation overlap.
            2. IDEMPOTENCY SCOPING. `Idempotency-Key` is an agent-chosen string and the uniqueness
               constraint is on `(agent_id, idempotency_key)` TOGETHER (idempotency.schema.json).
               Without an agent id the platform cannot tell one agent's retry from another agent's
               first attempt that happened to pick the same key.

            Purpose 2 applies to EVERY hook call, including a reply that carries a delivery token.
            Do not read this header as scaffolding for any one endpoint or variant: an earlier
            draft of this contract had a proactive reply variant, that variant was deleted, and
            this header is unaffected because idempotency scoping never depended on it.

            A LOOKUP HINT, NEVER AN IDENTITY CLAIM (S2-security-direction-ruling.md §1, signed). It
            is unauthenticated input read before any MAC is computed. Identity is established ONLY
            by a signature that verifies against a credential actually owned by this agent — a
            caller that names an agent it cannot sign for gets exactly the same answer as one that
            names itself and signs badly.

            EVERY REJECTION COLLAPSES INTO ONE RESPONSE: unknown agent id, malformed agent id,
            wrong secret, stale timestamp, future timestamp, malformed signature — one status, one
            body, indistinguishable. If "agent not found" were separable from "bad signature" this
            header would become an agent-enumeration oracle, which is the door closed on the
            registry surfaces in B2C.
          schema:
            type: string
            pattern: '^HMAC agt_[0-9A-HJKMNP-TV-Z]{26}/.+$'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: ./reply.schema.json
      responses:
        "202":
          description: |
            The reply was accepted for rendering. Acceptance is not delivery — the platform
            validates blocks, records the message and fans it out to connected surfaces.
          content:
            application/json:
              schema:
                type: object
                additionalProperties: false
                required: [message_id]
                properties:
                  message_id:
                    type: string
                    pattern: ^msg_[0-9A-HJKMNP-TV-Z]{26}$
                    description: Identifier of the created message, for correlating with delivery logs.
        "400":
          description: |
            The body is not a valid reply — malformed JSON, an unknown block type, or a block that
            fails the block schema.
          content:
            application/json:
              schema:
                $ref: ./common.schema.json#/$defs/error
        "401":
          description: |
            Signature verification failed. Identical for every cause — bad signature, stale
            timestamp, unknown agent — so the response cannot be used as an oracle.
          content:
            application/json:
              schema:
                $ref: ./common.schema.json#/$defs/error
        "413":
          description: The body exceeds the 1 MiB ingress limit. Send large artifacts as assets.
          content:
            application/json:
              schema:
                $ref: ./common.schema.json#/$defs/error
        "429":
          $ref: "#/components/responses/RateLimited"

  /hooks/v1/task:
    post:
      tags: [hooks]
      operationId: postTaskUpdate
      summary: Update the status or result of an assigned task.
      security:
        - agentSignature: []
      description: |
        AGENT-FACING — `snake_case` body, HMAC authentication, no session cookie
        (Architecture.md:379 — "task status/result updates").

        THE DELIVERY TOKEN SAYS WHICH TASK. There is no `task_id` field: `task.assigned` and
        `task.cancelled` are delivery events, so an agent working a task has received a delivery
        whose id already binds agent -> conversation -> task. Accepting a task id would let an
        agent holding a delivery for task A name task B, catchable only by a handler-side
        comparison somebody has to remember to write.

        `status` accepts only the four transitions an ASSIGNEE drives — `in_progress`,
        `awaiting_approval`, `completed`, `failed`. `created`/`assigned` are platform-side and
        `cancelled` is delivered TO the agent, so none of them is an agent's to assert.

        Sign and send exactly as for `/hooks/v1/reply`: same signature scheme, same
        `Authorization` agent hint, same collapsed rejection.
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          description: |
            The delivery id being answered, or an agent-chosen key. Reused unchanged on every retry
            so a repeat is collapsed rather than applied twice.
          schema:
            type: string
            minLength: 8
            maxLength: 128
        - name: X-Platform-Signature
          in: header
          required: true
          description: 't=<unix>,v1=<hex hmac-sha256 over "<t>.<raw body>">'
          schema:
            type: string
        - name: Authorization
          in: header
          required: true
          description: |
            `Authorization: HMAC <agent_id>/<...>` — identical in meaning, purpose and failure
            handling to the same header on `/hooks/v1/reply`; see that endpoint for the full rule.
            Published for the whole `/hooks/v1/*` surface (Architecture.md:383).
          schema:
            type: string
            pattern: '^HMAC agt_[0-9A-HJKMNP-TV-Z]{26}/.+$'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: ./task.schema.json
      responses:
        "202":
          description: |
            The update was accepted. Acceptance is not the transition — the platform applies it as
            one guarded UPDATE plus a ledger event in the same transaction, so an update that is
            illegal from the task's current state is rejected here even though it is well-formed.
          content:
            application/json:
              schema:
                type: object
                additionalProperties: false
                required: [applied]
                properties:
                  applied:
                    type: boolean
                    description: Whether the transition was applied to the task.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          description: |
            Signature verification failed. ONE collapsed answer for every cause — unknown agent id,
            wrong secret, stale or future timestamp, malformed header — so the endpoint cannot be
            used to learn which agents exist.
          content:
            application/json:
              schema:
                $ref: ./common.schema.json#/$defs/error
        "409":
          description: |
            The transition is not legal from the task's current state (Architecture.md:217 — guarded
            `UPDATE ... WHERE status IN (...)`). Distinct from 400: the body was well-formed, the
            state machine refused it.
          content:
            application/json:
              schema:
                $ref: ./common.schema.json#/$defs/error
        "429":
          $ref: "#/components/responses/RateLimited"

  /hooks/v1/tools/invoke:
    post:
      tags: [hooks]
      operationId: invokeTool
      summary: Ask the platform to mediate a tier-2 action on the user's behalf.
      security:
        - agentSignature: []
      description: |
        AGENT-FACING — `snake_case` body, HMAC authentication, no session cookie
        (Architecture.md 7.9, 247, 380).

        SHAPE PUBLISHED AT S4-C0; THE EVALUATION HALF IS LIVE AS OF S4-B1 — grant lookup and policy
        evaluation (`allow | require_approval | deny`, Architecture.md 7.5) run for real on this
        endpoint. Connector EXECUTION (credential injection, connector dispatch — the mediated call
        actually reaching the third party) stays S5's `toolproxy` implementation; this operation
        stops at minting a tier-2 approval or denying/allowing the request, never at executing it.

        (This paragraph previously said the endpoint was unrouted until S5. That was accurate at
        C0's own commit but became stale the moment the B1 scope ruling put the evaluation half in
        this sprint — a sequencing sentence surviving past the plan it described, the same
        prose-narrates-its-own-history defect the `grantedScopes` fix in `registry.schema.json`
        named. Corrected here rather than left for the next reader to trust literally.)

        NAMING A SCOPE GRANTS NOTHING. See `toolInvokeBody.requested_scope`'s own description for
        the three independent checks (manifest declaration, membership grant, live policy
        evaluation) that must all hold before this call can succeed — none of them expressible by
        the request itself.

        Sign and send exactly as for `/hooks/v1/reply`: same signature scheme, same `Authorization`
        agent hint, same collapsed 401.
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          description: |
            The delivery id being answered, or an agent-chosen key. Reused unchanged on every retry
            so a repeat invocation is collapsed rather than executed twice — load-bearing here more
            than anywhere else on this surface, since a duplicated tier-2 call can spend real money
            or send a real message on the user's behalf.
          schema:
            type: string
            minLength: 8
            maxLength: 128
        - name: X-Platform-Signature
          in: header
          required: true
          description: 't=<unix>,v1=<hex hmac-sha256 over "<t>.<raw body>">'
          schema:
            type: string
        - name: Authorization
          in: header
          required: true
          description: |
            `Authorization: HMAC <agent_id>/<...>` — identical in meaning, purpose and failure
            handling to the same header on `/hooks/v1/reply`; see that endpoint for the full rule.
            Published for the whole `/hooks/v1/*` surface (Architecture.md:383).
          schema:
            type: string
            pattern: '^HMAC agt_[0-9A-HJKMNP-TV-Z]{26}/.+$'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: ./toolproxy.schema.json#/$defs/toolInvokeBody
      responses:
        "202":
          description: |
            Accepted for mediated execution. Acceptance is not completion: `allow` proceeds
            asynchronously and results arrive as a `tool.result` delivery event
            (delivery.schema.json); `require_approval` creates a `policy.schema.json` approval and
            nothing executes until a user session resolves it. WIRED AS OF S4-B1 for the
            evaluation half (grant check + policy decision + approval minting on
            `require_approval`); connector EXECUTION behind an `allow` decision stays S5 —
            (this sentence previously said the whole shape was unwired until S5, written under
            C0's dispatch before the B1 scope ruling existed; corrected here in the SAME freeze as
            this operation's own description paragraph, per the prose-narrates-its-own-history
            pattern that correction itself named).
          content:
            application/json:
              schema:
                type: object
                additionalProperties: false
                required: [decision]
                properties:
                  decision:
                    type: string
                    enum: [allowed, pending_approval, denied]
                    description: The policy engine's immediate answer. `pending_approval` means an approval object now exists; the agent learns the outcome via the `tool.result` delivery event once a user resolves it.
                  approval_id:
                    $ref: ./common.schema.json#/$defs/approvalId
                    description: Present if and only if `decision` is `pending_approval`.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          description: |
            Signature verification failed. ONE collapsed answer for every cause, matching every
            other `/hooks/v1/*` endpoint's rule.
          content:
            application/json:
              schema:
                $ref: ./common.schema.json#/$defs/error
        "403":
          description: The requested scope was never granted by this user to this agent.
          content:
            application/json:
              schema:
                $ref: ./common.schema.json#/$defs/error
        "429":
          $ref: "#/components/responses/RateLimited"

  /hooks/v1/stream:
    get:
      tags: [hooks]
      operationId: openRelayStream
      summary: Hold a connection open to receive deliveries.
      security:
        - agentSignature: []
      description: |
        AGENT-FACING. The transport for an agent with no address the platform can dial.

        `integration.webhook_url` must be `https://` and there is no localhost exemption, so an
        agent on a laptop cannot be reached by dialling it. A certificate solves TRUST; this solves
        ROUTING. The agent opens this connection and the platform pushes work down it.

        🔴 THE DELIVERY IS BYTE-IDENTICAL TO A DIALLED ONE. Each event carries the same
        `X-Platform-Signature`, the same `Authorization`, and the same raw body the platform would
        have POSTed — so identity is still verified PER MESSAGE, not once at handshake, and an
        agent written for the dialled transport runs here unchanged.

        SIGNING A REQUEST WITH NO BODY: the canonical string is `<unix>.` — the timestamp, the
        separator, and zero bytes. Stated because two implementations left to guess would guess
        differently, and the failure is a 401 that names the signature while both ends believe they
        are correct.

        The connection is RE-VALIDATED on each heartbeat, not only at open: a socket authenticated
        once would keep receiving deliveries after its credential was revoked, and the connection
        would be the last thing holding the old authority.

        Events are `text/event-stream`:
          - `ready`      once, at open, so a client can tell connected-and-idle from still-connecting
          - `delivery`   `{delivery_id, signature, authorization, body_base64}` — the body is
                         base64 because the signature covers RAW BYTES and JSON string escaping
                         would change them
          - `heartbeat`  keep-alive, and the re-validation described above
      parameters:
        - name: X-Platform-Signature
          in: header
          required: true
          description: |
            't=<unix>,v1=<hex hmac-sha256 over "<t>.">' — the timestamp, the separator, and ZERO
            bytes, because this request has no body. Declared rather than merely described: a
            generated client sends what is declared.
          schema:
            type: string
        - name: Authorization
          in: header
          required: true
          description: '`HMAC <agent_id>/<credential_id>` — a lookup hint, never an identity claim.'
          schema:
            type: string
      responses:
        "200":
          description: The stream is open. Deliveries arrive as events.
          content:
            text/event-stream:
              schema:
                type: string
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "429":
          $ref: "#/components/responses/RateLimited"

  /hooks/v1/deliveries/{deliveryId}/ack:
    post:
      tags: [hooks]
      operationId: acknowledgeRelayDelivery
      summary: Report what the agent did with a relayed delivery.
      security:
        - agentSignature: []
      description: |
        AGENT-FACING. The transported form of the `202` a dialled agent returns.

        Without it the platform would know only that bytes left the building — which is exactly the
        "green 202 and silence" failure the relay exists to remove. Report the status the agent's
        own handler returned, INCLUDING a refusal: "the agent rejected this" and "nobody answered"
        must not reach the platform as the same outcome.

        Answers 200 whether or not the platform was still waiting, and says which in `matched`. An
        acknowledgement that arrives late is not the agent's fault — a retry, a restart or a slow
        network all produce one — so refusing it would teach a correct client to treat its own
        correct behaviour as a failure.
      parameters:
        - name: deliveryId
          in: path
          required: true
          schema:
            type: string
        - name: X-Platform-Signature
          in: header
          required: true
          description: 't=<unix>,v1=<hex hmac-sha256 over "<t>.<raw body>">'
          schema:
            type: string
        - name: Authorization
          in: header
          required: true
          description: '`HMAC <agent_id>/<credential_id>` — a lookup hint, never an identity claim.'
          schema:
            type: string
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              properties:
                status:
                  type: integer
                  description: The HTTP status the local agent returned. Defaults to 202 when absent.
                detail:
                  type: string
                  description: The agent's message when it refused, recorded on the delivery row.
                  maxLength: 2000
      responses:
        "200":
          description: The acknowledgement was received.
          content:
            application/json:
              schema:
                type: object
                additionalProperties: false
                required: [acknowledged, matched]
                properties:
                  acknowledged:
                    type: boolean
                  matched:
                    type: boolean
                    description: Whether the platform was still waiting for this delivery.
        "401":
          $ref: "#/components/responses/Unauthenticated"

  /v1/auth/cli-tokens:
    post:
      tags: [auth]
      operationId: mintCliToken
      summary: Mint a token to paste into a CLI
      description: |
        Called from an already-signed-in browser. The session cookie is `HttpOnly` and therefore
        unreadable by any script — deliberately, so injected script cannot exfiltrate it — which also
        means a terminal has nothing to copy. This endpoint is the only supported way a CLI obtains a
        credential; there is no back door and none should be built (no cookie scraping, no headless
        browser driving the web app).

        🔴 THE SECRET IS RETURNED EXACTLY ONCE. Same contract as agent credentials, same reason: a
        credential the server can re-display is one a compromised session can re-steal. Lost it? Mint
        another and revoke this one.

        ⚠️ SCOPE — READ THIS BEFORE BUILDING AGAINST IT. Measured at the composition root
        (`cmd/server/main.go:240`): ONE `RequireSession` guard wraps every module route, and there is
        no per-request permission model beyond "is signed in". A token accepted as a session
        equivalent therefore unlocks the entire console surface, so a leaked token is a lost account
        until it is revoked. Narrowing it is a real design decision, not a default — it is recorded
        as OPEN in the S10-B2 report and belongs to the user, not to this document.
      security:
        - sessionCookie: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: ./auth.schema.json#/$defs/cliTokenMint
      responses:
        "201":
          description: Token minted. Contains the only copy of the secret you will receive.
          content:
            application/json:
              schema:
                $ref: ./auth.schema.json#/$defs/cliTokenMintResult
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "429":
          $ref: "#/components/responses/RateLimited"
    get:
      tags: [auth]
      operationId: listCliTokens
      summary: The caller's CLI tokens, live and revoked
      description: |
        The visibility half, and the one that makes the other two constraints usable: expiry and
        revocation are mechanisms, but a stolen token nobody can SEE is a token nobody revokes.

        Revoked tokens REMAIN LISTED rather than vanishing — someone checking whether they already
        killed a lost laptop's token must be able to see that they did. An emptied list and a
        successful revocation would otherwise look identical.
      security:
        - sessionCookie: []
      responses:
        "200":
          description: Every CLI token belonging to the caller, newest first.
          content:
            application/json:
              schema:
                $ref: ./auth.schema.json#/$defs/cliTokenList
        "401":
          $ref: "#/components/responses/Unauthenticated"

  /v1/auth/cli-tokens/{tokenId}:
    delete:
      tags: [auth]
      operationId: revokeCliToken
      summary: Revoke one CLI token
      description: |
        IDEMPOTENT: revoking an already-revoked token answers 200 with `alreadyRevoked: true`, never
        409. Someone revoking a token they believe is stolen must never be told "error" — they would
        not know whether it worked, and the hesitation is the entire cost of getting this wrong.

        A token id belonging to someone else answers exactly as one that does not exist: whether a
        given token id is real is not information this endpoint gives away.
      security:
        - sessionCookie: []
      parameters:
        - name: tokenId
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: The token can no longer authenticate.
          content:
            application/json:
              schema:
                $ref: ./auth.schema.json#/$defs/cliTokenRevokeResult
        "401":
          $ref: "#/components/responses/Unauthenticated"

  /v1/agents/{agentId}/conversations:
    get:
      operationId: listAgentConversations
      summary: The conversations one agent takes part in
      description: |
        DEV-TIER, publisher-owner only. Which conversations this agent is in, when each last moved,
        and how many messages it holds — enough to render a list, and deliberately nothing more.

        🔴 THE HUMAN IS NAMED BY THE PER-AGENT PSEUDONYM AND BY NOTHING ELSE. It is the SAME id the
        agent already received in every delivery envelope (`deliveryUser.id`), so an owner can
        correlate this list against their own webhook logs exactly and learns nothing new about who
        the person is. Real identity is a CONSENT-GATED LADDER delivered in the payload only when the
        user granted it (Architecture.md:364); this endpoint is an observation surface, not a second
        door into that ladder.

        ORDERED BY DERIVED ACTIVITY, NOT BY A RAW COLUMN. `lastActivityAt` is the last message time
        when there is one and the creation time otherwise. Measured before this endpoint was written:
        39 of 69 live conversations carry a NULL `last_message_at` and 14 real messages sit inside
        them, so ordering on the raw column with NULLS LAST buries real conversations at the bottom
        of the owner's list while the response still looks correct (S10-B1).

        An agent you do not own reads as an EMPTY LIST, not 403 — the same read-hide idiom the
        delivery log uses. Whether someone else's agent exists is not information this endpoint
        gives away.
      security:
        - sessionCookie: []
      parameters:
        - name: agentId
          in: path
          required: true
          schema:
            type: string
        - name: cursor
          in: query
          required: false
          description: Opaque cursor from a previous response's `nextCursor`.
          schema:
            type: string
            maxLength: 512
        - name: limit
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 50
      responses:
        "200":
          description: |
            The agent's conversations, most recently active first. An agent the caller does not own,
            and an agent that does not exist, both answer with an empty list — deliberately
            indistinguishable.
          content:
            application/json:
              schema:
                $ref: ./conversation.schema.json#/$defs/agentConversationList
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "429":
          $ref: "#/components/responses/RateLimited"

  /v1/agents/{agentId}/metrics:
    get:
      operationId: getAgentMetrics
      summary: Platform-measured reliability for one agent
      description: |
        DEV-TIER, publisher-owner only. The MEASURED half of an agent's reputation — error rate,
        latency, and (relay agents only) uptime, computed from the platform's own delivery records
        over a trailing window.

        WHY THESE NUMBERS ARE EVIDENCE AND STARS ARE NOT: every input is written by the platform's
        delivery worker from its own clock, in the same transaction as the outcome it describes. The
        agent's owner has no write path to any of them, so an agent cannot improve its numbers except
        by actually being more reliable. Community-supplied figures (ratings, install counts) are a
        different evidence class and are deliberately absent from this surface.

        🔴 A METRIC THAT CANNOT BE HONESTLY COMPUTED IS OMITTED, NEVER ZEROED. `errorRate` is absent
        when nothing settled in the window; `latencyMeanMs` is absent when no delivery produced a
        measurable reply; `latencyMedianMs` is absent ALWAYS as of 2026-08-09 (it needs a mergeable
        sketch that has not been added, and serving the mean under that key would be a fabricated
        number). `uptime` is absent for webhook-transport agents, whose availability between
        deliveries the platform never observes. Read an absent key as "we do not know", never as 0.

        TWO SAMPLE COUNTS, because the denominators differ: `errorSampleSize` counts settled
        deliveries, `latencySampleSize` counts deliveries that actually produced a reply. They
        diverge sharply in practice.

        An agent you do not own reads as `found:false`, not 403 — the same read-hide idiom the
        delivery log and public profile use, so this route cannot be used to test which agent ids
        exist.
      tags: [registry]
      parameters:
        - name: agentId
          in: path
          required: true
          schema: { type: string }
        - name: window
          in: query
          required: false
          description: Trailing window. Defaults to 7d.
          schema:
            type: string
            enum: ["24h", "7d", "30d"]
      responses:
        "200":
          description: The agent's measured metrics, or a not-found envelope.
          content:
            application/json:
              schema:
                $ref: ./registry.schema.json#/$defs/agentMetricsResult
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthenticated"

  /v1/agents/{agentId}/metrics/daily:
    get:
      operationId: getAgentMetricsDaily
      summary: Per-day request counts for one agent
      description: |
        DEV-TIER, publisher-owner only. The DENSE per-day series behind the Dev Console's requests
        chart — how many deliveries this agent received each day, and how many exhausted their
        retries.

        🔴 DENSE, NEWEST LAST, NO GAPS. Every day in the requested range is returned, including days
        with no traffic at all, and `days` always has exactly `requestedDays` entries. A sparse
        series is not a smaller truth but a wrong one: a chart connects the points it is given, so a
        week of silence delivered as "no entries" renders as a week of steady work.

        🔴 A ZERO HERE IS A MEASURED COUNT, and this route deliberately does NOT follow the
        omit-never-zero rule that GET /v1/agents/{agentId}/metrics applies. That rule protects RATES
        and MEANS, which are undefined over an empty denominator. A count of deliveries on a quiet
        day is honestly zero, and omitting it would be the less truthful choice.

        SOURCES AND THE FOLD. Days are assembled from the platform's own delivery rollups: the daily
        table for days the fold job has closed, the hourly table for today (always) and for any
        recent day the fold has not reached yet. A day present in both is taken from the DAILY
        table, never summed — the fold REPLACES rather than accumulates, so presence in both means
        it completed.

        `requests` and `failures` MUST NOT BE DIVIDED. A delivery dispatched near midnight can
        exhaust the following day, so `failures` can exceed `requests` on a given day. The honest
        error rate is GET /v1/agents/{agentId}/metrics, which divides over matched terminal counts.

        An agent you do not own reads as `found:false`, not 403 — the same read-hide idiom the
        sibling metrics route and the delivery log use. An agent you DO own that has never received
        a delivery reads as `found:true` with every day zeroed; "not yours" and "no traffic yet" are
        different facts and the envelope keeps them apart.
      tags: [registry]
      parameters:
        - name: agentId
          in: path
          required: true
          schema: { type: string }
        - name: days
          in: query
          required: false
          description: |
            How many days to return, ending with today. Defaults to 14.

            OUT-OF-RANGE IS REFUSED WITH 400, NEVER CLAMPED. A caller asking for 365 and silently
            receiving 90 labels the result "the last year" — a wrong answer under a 200, with
            nothing in the response saying a substitution happened. Same ruling as the `window`
            parameter on the sibling metrics route.
          schema:
            type: integer
            minimum: 1
            maximum: 90
            default: 14
      responses:
        "200":
          description: The agent's dense per-day series, or a not-found envelope.
          content:
            application/json:
              schema:
                $ref: ./registry.schema.json#/$defs/agentMetricsDailyResult
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthenticated"

  /v1/agents/{agentId}/deliveries:
    get:
      operationId: listDeliveries
      summary: The delivery log for one agent
      description: |
        DEV-TIER, publisher-owner only. What the platform tried to send this agent, when, and how it
        went — the Dev Console's delivery-log explorer reads exactly this.

        `attemptCount` and `lastError` are the board #51 fields: a delivery that has been attempted
        never reads `attemptCount: 0`, and one that recovered does not carry the error it recovered
        from. A log that reproduces either lie is worse than no log, because it is read as history.

        An agent you do not own reads as an EMPTY LIST, not 403 — the same read-hide idiom every
        single-item lookup uses. Whether someone else's agent exists is not information this endpoint
        gives away.
      security:
        - sessionCookie: []
      parameters:
        - name: agentId
          in: path
          required: true
          schema:
            type: string
        - name: status
          in: query
          required: false
          description: Filter to one lifecycle state.
          schema:
            type: string
            enum: [pending, delivered, failed, exhausted]
        - name: limit
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 50
      responses:
        "200":
          description: |
            The agent's deliveries, newest first. An agent the caller does not own, and an agent that
            does not exist, both answer with an empty list — deliberately indistinguishable.
          content:
            application/json:
              schema:
                $ref: ./delivery.schema.json#/$defs/deliveryLogPage
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "429":
          $ref: "#/components/responses/RateLimited"

  /v1/agents/{agentId}/deliveries/{deliveryId}:
    get:
      operationId: getDelivery
      summary: One delivery's record
      description: |
        DEV-TIER, publisher-owner only. Uses the shared get envelope: `found: false` with 200 is a
        normal answer, not an error.

        A delivery belonging to an agent the caller does not own answers `found: false` — identical
        to one that never existed. Absence and refusal must not be distinguishable, or the endpoint
        becomes an existence oracle for other publishers' traffic.
      security:
        - sessionCookie: []
      parameters:
        - name: agentId
          in: path
          required: true
          schema:
            type: string
        - name: deliveryId
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: "The delivery, or `found: false` when it does not exist or is not the caller's."
          content:
            application/json:
              schema:
                $ref: ./delivery.schema.json#/$defs/deliveryLogResult
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "429":
          $ref: "#/components/responses/RateLimited"

  /v1/agents/{agentId}/deliveries/{deliveryId}/redeliver:
    post:
      operationId: redeliverDelivery
      summary: Send a failed delivery again.
      description: |
        DEV-TIER, publisher-owner only. Re-attempts a delivery that did not reach the agent, using
        the agent's CURRENT webhook and CURRENT credential — not the ones in force when it first
        failed. That is the point: the usual reason a delivery failed is that one of those two was
        wrong, and the fix is to correct it and try again.

        A NEW DELIVERY ROW IS CREATED; the original is never mutated. The old row is the record of
        what happened at that time, and rewriting it would erase the very evidence the operator
        used to decide to retry. The response carries the NEW `deliveryId`, so the caller watches
        the new attempt rather than re-reading the old one and wondering why it still says failed.

        ONLY A SETTLED FAILURE MAY BE RE-DRIVEN. `pending` answers 409: a delivery still in flight
        has a live queue job, and a second one would deliver the same event twice with no
        idempotency key to collapse them. `delivered` answers 409 too — re-sending a message the
        agent already accepted is a duplicate, not a repair.

        A delivery belonging to an agent the caller does not own answers 403, identical to one that
        never existed, for the same existence-oracle reason `getDelivery` states.
      security:
        - sessionCookie: []
      parameters:
        - name: agentId
          in: path
          required: true
          schema:
            type: string
        - name: deliveryId
          in: path
          required: true
          schema:
            type: string
      responses:
        "202":
          description: A new delivery was queued. The body carries its id.
          content:
            application/json:
              schema:
                $ref: ./delivery.schema.json#/$defs/redeliverResult
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "403":
          description: |
            No such delivery, or it does not belong to an agent the caller owns — ONE answer for
            both, deliberately. A 404 here would separate "does not exist" from "exists but is not
            yours", which is the existence oracle B2C closed.
          content:
            application/json:
              schema:
                $ref: ./common.schema.json#/$defs/error
        "409":
          description: The delivery is not in a re-drivable state (still pending, or already delivered).
          content:
            application/json:
              schema:
                $ref: ./common.schema.json#/$defs/error
        "429":
          $ref: "#/components/responses/RateLimited"

  /v1/approvals:
    get:
      tags: [policy]
      operationId: listApprovals
      summary: List the signed-in caller's own pending and resolved approvals.
      description: |
        SPEC.md S4 — the approvals inbox: "the one place every tier-2 pending action surfaces,
        regardless of which conversation spawned it." SCOPED TO THE CALLER'S OWN APPROVALS ONLY —
        no parameter names whose approvals to list, matching `listConversations`'s reasoning
        exactly: a parameter naming another party would be a claim rather than a reference (the
        squatting-hole shape, B2D). An agent cannot reach this endpoint at all: no `agentSignature`
        alternative is declared, so only a session cookie authenticates it — this is the read half
        of verifier independence (Architecture.md 222-223), the write half is on `resolve` below.
      parameters:
        - name: state
          in: query
          required: false
          description: Filter to one approval state. Omitted returns every state.
          schema:
            $ref: ./policy.schema.json#/$defs/approvalState
        - name: cursor
          in: query
          required: false
          schema: { type: string, maxLength: 512 }
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
      responses:
        "200":
          description: A page of the caller's approvals.
          content:
            application/json:
              schema:
                $ref: ./policy.schema.json#/$defs/approvalList
        "401":
          $ref: "#/components/responses/Unauthenticated"

  /v1/approvals/{approvalId}/resolve:
    parameters:
      - name: approvalId
        in: path
        required: true
        schema: { type: string, pattern: "^apr_[0-9A-HJKMNP-TV-Z]{26}$" }
    post:
      tags: [policy]
      operationId: resolveApproval
      summary: Approve or deny a pending approval.
      description: |
        VERIFIER INDEPENDENCE BY CONSTRUCTION (Architecture.md 222-223): this operation declares no
        security scheme of its own, so it inherits ONLY the document's global `sessionCookie`
        default — there is no `agentSignature` alternative anywhere on this path. An agent
        authenticated by HMAC cannot reach this endpoint under any request shape; the contract
        itself has no channel for it, not a check that could be forgotten in a handler.

        RESOLUTION IDEMPOTENCY — see `resolveApprovalBody`'s own description for the full
        three-case table (repeat-same-decision succeeds idempotently; opposite-decision-on-resolved
        409s; resolve-after-expiry 409s and durably writes the expiry if the sweep had not yet).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: ./policy.schema.json#/$defs/resolveApprovalBody
      responses:
        "200":
          description: Resolved (or idempotently re-confirmed, see description).
          content:
            application/json:
              schema:
                $ref: ./policy.schema.json#/$defs/approvalResolved
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "403":
          description: |
            No approval with this id is visible to the caller. 403 rather than 404: this is a WRITE
            on a caller-scoped path (`GET /v1/approvals` is the read side, and its list is already
            scoped so a 404 question cannot arise there) — a 404 here would let a caller distinguish
            "this approval id does not exist" from "it exists but belongs to someone else", which is
            the same existence oracle B2C closed for reads, one write further along.
          content:
            application/json:
              schema:
                $ref: ./common.schema.json#/$defs/error
        "409":
          description: |
            Already resolved with the opposite decision (`approval_already_resolved`), or expired
            (`approval_expired`) — see `resolveApprovalBody`'s description for the full table.
          content:
            application/json:
              schema:
                $ref: ./common.schema.json#/$defs/error

  # ── asset module (S12-T7, DECISIONS.md 2026-08-07) ──────────────────────────────────────────
  #
  # Media rule (Architecture.md:177): bytes NEVER traverse this API. Every operation below issues
  # or consumes a TICKET — a signed URL, or a confirmation that reads storage back — never a body
  # of media bytes. Two audiences, same lifecycle, different casing (see this document's own
  # casing-convention block above): /v1/assets* is human-facing camelCase, /hooks/v1/assets* is
  # agent-facing snake_case (Architecture.md 8.5).
  /v1/assets:
    post:
      tags: [asset]
      operationId: issueUploadTicketHuman
      summary: Request a signed URL to upload a media file.
      description: |
        HUMAN-FACING — `camelCase` body, session cookie.

        Declares intent BEFORE any bytes move (Architecture.md 7.14b). The returned `assetId` is
        allocated now and stable for the whole lifecycle — reference it in a message draft while
        the upload is still in flight. PUT the file to `uploadUrl` directly (never through this
        API), then call `POST /v1/assets/{assetId}/confirm`.

        THE PUT MUST SET Content-Type TO THE SAME `mimeType` DECLARED BELOW (DEBT 24). Confirmation
        reads the registered type back from what object storage recorded at upload time, never from
        this request body again — a PUT sent with no Content-Type, or a different one, registers
        with whatever storage actually saw, silently, with no error at any step.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: ./asset.schema.json#/$defs/uploadTicketRequestHuman
      responses:
        "201":
          description: Upload ticket issued.
          content:
            application/json:
              schema:
                $ref: ./asset.schema.json#/$defs/uploadTicketResultHuman
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "422":
          description: The declared size exceeds the platform's ceiling, or the mime type is malformed.
          content:
            application/json:
              schema:
                $ref: ./common.schema.json#/$defs/error
        "503":
          description: Object storage is not configured on this deployment.
          content:
            application/json:
              schema:
                $ref: ./common.schema.json#/$defs/error

  /v1/assets/{assetId}/confirm:
    parameters:
      - name: assetId
        in: path
        required: true
        description: Identifier of the asset, prefixed `ast_`.
        schema:
          type: string
          pattern: ^ast_[0-9A-HJKMNP-TV-Z]{26}$
    post:
      tags: [asset]
      operationId: confirmAssetHuman
      summary: Register an asset once its upload has completed.
      description: |
        HUMAN-FACING — `camelCase`, session cookie. NO REQUEST BODY: the server re-derives
        mime type, size and sha256 by reading the uploaded object back from storage itself, never
        from a caller-supplied claim (migration 00020_asset_schema.sql). A body here would just be
        a second, untrustworthy copy of what this read already proves.

        Idempotent: confirming the same assetId twice re-reads the same object and returns the
        same row, so a retry after a dropped response is safe with no separate replay key.

        NEVER 404 as an existence oracle — a WRITE on a caller-scoped path (B2C, same rule as
        `DELETE /v1/agents/{agentId}/credentials/{credentialId}`, applied here to a 200 rather than
        a 204 because this write has a real body to return). `found:false` covers BOTH "nothing was
        ever uploaded to this ticket" AND "this assetId belongs to someone else" with the identical
        answer — see `assetResultHuman`'s own schema description for why the two collapse by
        construction, not by a check this handler adds.
      responses:
        "200":
          description: "`found:true` with the registered asset, or `found:false` — see description."
          content:
            application/json:
              schema:
                $ref: ./asset.schema.json#/$defs/assetResultHuman
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "503":
          description: Object storage is not configured on this deployment.
          content:
            application/json:
              schema:
                $ref: ./common.schema.json#/$defs/error

  /v1/assets/{assetId}/url:
    parameters:
      - name: assetId
        in: path
        required: true
        description: Identifier of the asset, prefixed `ast_`.
        schema:
          type: string
          pattern: ^ast_[0-9A-HJKMNP-TV-Z]{26}$
    get:
      tags: [asset]
      operationId: getAssetDownloadURLHuman
      summary: Mint a fresh signed download URL for an asset.
      description: |
        HUMAN-FACING — `camelCase`, session cookie.

        NEVER 404 as an existence oracle (B2C — same rule as `GET /v1/agents/{agentId}/credentials`).
        `found:false` covers both "no such asset" and "it exists but is not the caller's" with the
        identical answer. Ownership is IN the lookup query, never checked after a fetch — see
        `downloadURLResultHuman`'s own schema description.

        Re-issue on demand: the URL attached to a delivery or an earlier confirm response expires
        (Architecture.md 8.5), and this is how a caller gets a fresh one without re-uploading.
      responses:
        "200":
          description: "`found:true` with a fresh signed download URL, or `found:false` — see description."
          content:
            application/json:
              schema:
                $ref: ./asset.schema.json#/$defs/downloadURLResultHuman
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "503":
          description: Object storage is not configured on this deployment.
          content:
            application/json:
              schema:
                $ref: ./common.schema.json#/$defs/error

  /hooks/v1/assets:
    post:
      tags: [hooks]
      operationId: issueUploadTicketAgent
      summary: Request a signed URL to upload a media file (agent-facing).
      security:
        - agentSignature: []
      description: |
        AGENT-FACING — `snake_case` body, HMAC authentication, no session cookie
        (Architecture.md 8.5: "Outbound from agent").

        THIS OPERATION DECLARES NO REPLAY-PROTECTION HEADER, unlike `/hooks/v1/reply` and
        `/hooks/v1/tools/invoke` (see those operations' own parameter list for the one they
        require): issuing a second ticket on retry is harmless — both remain valid, independent
        signed URLs pointing at independent, never-colliding storage keys — so there is no replay
        risk such a header would protect against. Sign exactly as for `/hooks/v1/reply`: same
        signature scheme, same `Authorization` agent hint, same collapsed 401.

        Same size/mime constraints as the human-facing path (Architecture.md 8.5: "Same size/mime
        constraints and AV scanning as user uploads").

        THE PUT TO `upload_url` MUST SET Content-Type TO THE SAME `mime_type` DECLARED BELOW
        (DEBT 24) — see `issueUploadTicketHuman`'s own description for why: confirmation reads the
        registered type back from what object storage recorded at upload time, never from this
        request body again.
      parameters:
        - name: X-Platform-Signature
          in: header
          required: true
          description: 't=<unix>,v1=<hex hmac-sha256 over "<t>.<raw body>">'
          schema:
            type: string
        - name: Authorization
          in: header
          required: true
          description: |
            `Authorization: HMAC <agent_id>/<...>` — the agent-facing credential header, published
            for the whole `/hooks/v1/*` surface. See `/hooks/v1/reply`'s own parameter description
            for the full two-purpose rationale (key resolution + idempotency scoping); this
            operation uses purpose 1 only, since it declares no replay-protection header.
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: ./asset.schema.json#/$defs/uploadTicketRequestAgent
      responses:
        "201":
          description: Upload ticket issued.
          content:
            application/json:
              schema:
                $ref: ./asset.schema.json#/$defs/uploadTicketResultAgent
        "401":
          description: |
            Unauthenticated. ONE collapsed response for every cause — unknown agent id, bad
            signature, stale timestamp — matching every other `/hooks/v1/*` endpoint.
          content:
            application/json:
              schema:
                $ref: ./common.schema.json#/$defs/error
        "422":
          description: The declared size exceeds the platform's ceiling, or the mime type is malformed.
          content:
            application/json:
              schema:
                $ref: ./common.schema.json#/$defs/error
        "503":
          description: Object storage is not configured on this deployment.
          content:
            application/json:
              schema:
                $ref: ./common.schema.json#/$defs/error

  /hooks/v1/assets/{assetId}/confirm:
    parameters:
      - name: assetId
        in: path
        required: true
        description: Identifier of the asset, prefixed `ast_`.
        schema:
          type: string
          pattern: ^ast_[0-9A-HJKMNP-TV-Z]{26}$
    post:
      tags: [hooks]
      operationId: confirmAssetAgent
      summary: Register an asset once its upload has completed (agent-facing).
      security:
        - agentSignature: []
      description: |
        AGENT-FACING — `snake_case`, HMAC authentication. NO REQUEST BODY — see
        `confirmAssetHuman`'s description for why: the server re-derives every fact from the
        object storage read itself. Idempotent for the same reason stated there.

        NEVER 404 as an existence oracle, held to the SAME discipline as the human-facing surface
        even though `TestNoCallerScopedPathAnswers404` does not reach `/hooks/*` (see
        `getAssetDownloadURLAgent`'s own description for that guard's exact boundary). `found:false`
        covers both "nothing was uploaded to this ticket" and "this assetId belongs to another
        actor" with the identical answer — see `assetResultAgent`'s schema description for why the
        two collapse by construction.
      parameters:
        - name: X-Platform-Signature
          in: header
          required: true
          description: 't=<unix>,v1=<hex hmac-sha256 over "<t>.">'
          schema:
            type: string
        - name: Authorization
          in: header
          required: true
          schema:
            type: string
      responses:
        "200":
          description: "`found:true` with the registered asset, or `found:false` — see description."
          content:
            application/json:
              schema:
                $ref: ./asset.schema.json#/$defs/assetResultAgent
        "401":
          description: Unauthenticated (collapsed, see /hooks/v1/assets).
          content:
            application/json:
              schema:
                $ref: ./common.schema.json#/$defs/error
        "503":
          description: Object storage is not configured on this deployment.
          content:
            application/json:
              schema:
                $ref: ./common.schema.json#/$defs/error

  /hooks/v1/conversations/{conversationId}/messages/{messageId}/assets/{assetId}/url:
    parameters:
      - name: conversationId
        in: path
        required: true
        schema: { type: string, pattern: "^cnv_[0-9A-HJKMNP-TV-Z]{26}$" }
      - name: messageId
        in: path
        required: true
        schema: { type: string, pattern: "^msg_[0-9A-HJKMNP-TV-Z]{26}$" }
      - name: assetId
        in: path
        required: true
        schema: { type: string, pattern: "^ast_[0-9A-HJKMNP-TV-Z]{26}$" }
    get:
      operationId: getMessageAssetURLForAgent
      summary: Signed URL for an asset referenced by a message this agent received
      security:
        - agentSignature: []
      description: |
        AGENT-SIGNED. The agent's half of asset resolution: a delivered `image`, `file` or `audio`
        block carries an `asset_id` and nothing else, and this is how those bytes are fetched.

        🔴 WHY THIS EXISTS ALONGSIDE `GET /hooks/v1/assets/{assetId}/url`, AND WHY THAT ONE IS NOT
        SIMPLY WIDENED. The other route is OWNER-scoped — it answers only for assets the agent
        itself uploaded, which is correct and must stay that way: relaxing it would let any agent
        fetch any asset by id, a cross-tenant enumeration oracle. This route instead authorizes by
        REACHABILITY — the caller must participate in the conversation, the message must exist, and
        the asset must ACTUALLY BE REFERENCED BY THAT MESSAGE'S BLOCKS. An agent can only resolve
        what it was already sent.

        Do not "simplify" this into an ownership check. Ownership is the wrong question here: the
        asset belongs to the HUMAN who attached it, and the agent's entitlement comes from holding a
        message that references it.

        SIGN IT AS A BODYLESS REQUEST: the canonical string is `<unix>.` — the timestamp, the
        separator, and zero bytes — exactly as for `GET /hooks/v1/stream`.

        `found: false` is ONE answer for FOUR causes — no such conversation, not a participant, no
        such message, or an asset that message does not reference. They are deliberately
        indistinguishable so this route cannot be used to probe which ids exist.

        The human twin is `GET /v1/conversations/{conversationId}/messages/{messageId}/assets/{assetId}/url`:
        same path shape, session-guarded, camelCase body. This one is snake_case, like the rest of
        `/hooks/v1/*`.
      tags: [delivery]
      responses:
        "200":
          description: A signed URL, or a not-found envelope.
          content:
            application/json:
              schema:
                $ref: ./asset.schema.json#/$defs/downloadURLResultAgent
        "401":
          $ref: "#/components/responses/Unauthenticated"

  /hooks/v1/assets/{assetId}/url:
    parameters:
      - name: assetId
        in: path
        required: true
        description: Identifier of the asset, prefixed `ast_`.
        schema:
          type: string
          pattern: ^ast_[0-9A-HJKMNP-TV-Z]{26}$
    get:
      tags: [hooks]
      operationId: getAssetDownloadURLAgent
      summary: Mint a fresh signed download URL for an asset (agent-facing).
      security:
        - agentSignature: []
      description: |
        AGENT-FACING — `snake_case`, HMAC authentication (Architecture.md 8.5: "Expired URLs are
        re-issued via GET /hooks/v1/assets/{id}/url").

        NEVER 404 AS AN EXISTENCE ORACLE, and held to that discipline even though
        `TestNoCallerScopedPathAnswers404` (internal/contract/contract_test.go) structurally
        excludes `/hooks/*` from its check. That exclusion's rationale is specific to an agent's OWN
        delivery history — it does not cover this operation, where an agent can name an ARBITRARY
        `asset_id` unrelated to any delivery it received. `asset.Store.DownloadURL` closes this by
        folding ownership into the query itself, the same way `registry.Store.RevokeCredential`
        does; `found:false` below is that closure's visible half, not a gap in it.
      parameters:
        - name: X-Platform-Signature
          in: header
          required: true
          description: 't=<unix>,v1=<hex hmac-sha256 over "<t>.">'
          schema:
            type: string
        - name: Authorization
          in: header
          required: true
          schema:
            type: string
      responses:
        "200":
          description: "`found:true` with a fresh signed download URL, or `found:false` — see description."
          content:
            application/json:
              schema:
                $ref: ./asset.schema.json#/$defs/downloadURLResultAgent
        "401":
          description: Unauthenticated (collapsed, see /hooks/v1/assets).
          content:
            application/json:
              schema:
                $ref: ./common.schema.json#/$defs/error
        "503":
          description: Object storage is not configured on this deployment.
          content:
            application/json:
              schema:
                $ref: ./common.schema.json#/$defs/error

# The PLATFORM→AGENT direction. Everything above this line is agent→platform or browser→platform.
#
# Board #59: this direction was declared NOWHERE for two sprints. The consequences were not
# theoretical — `X-Delivery-Id` was sent as `Idempotency-Key` and nothing could catch it (S2-C3),
# board #54's missing `Authorization` survived because no contract named the header set, and #57
# could not write a contract-driven conformance test because there was no operation to derive from.
# A direction with no declaration has no arbiter, so every fixture is hand-maintained by definition.
#
# DERIVED FROM THE CODE AND CAPTURED WIRE BYTES, never from memory of what we think we send:
# `backend/internal/delivery/worker.go` sets exactly these four headers, and
# `sprints/evidence/S3-C0-outbound-wire-capture.json` is a real POST recorded off the worker.
webhooks:
  delivery:
    post:
      operationId: receiveDelivery
      summary: The platform POSTs a delivery to an agent's registered webhook_url
      description: |
        AGENT-IMPLEMENTED. The agent hosts this endpoint; the platform calls it.

        Verify `X-Platform-Signature` over the RAW body before trusting anything, and deduplicate on
        `X-Delivery-Id` — a retry repeats the same id, and the platform's retry schedule is
        1m · 5m · 30m · 2h · 12h (five attempts).

        ACK FAST, ANSWER LATER. Return 2xx as soon as the delivery is durably accepted; the reply is
        a separate `POST /hooks/v1/reply` on your own schedule. A slow ACK is retried and becomes a
        duplicate.
      parameters:
        - name: X-Delivery-Id
          in: header
          required: true
          description: |
            The delivery's id, and the agent's DEDUPLICATION key. Repeated verbatim on every retry
            of the same delivery.

            NOT `Idempotency-Key`: the two are opposite directions of one idea and were transposed
            once already (S2-C3). `Idempotency-Key` is what an AGENT sends the platform on
            `/hooks/v1/*`; `X-Delivery-Id` is what the PLATFORM sends an agent.
          schema:
            type: string
            minLength: 8
            maxLength: 128
        - name: X-Platform-Signature
          in: header
          required: true
          description: 't=<unix>,v1=<hex hmac-sha256 over "<t>.<raw body>">' 
          schema:
            type: string
        - name: Authorization
          in: header
          required: true
          description: |
            `Authorization: HMAC <agent_id>/<credential_id>` — which agent this delivery is for and
            which credential signed it. Echo it back verbatim on the reply: board #54 was the reply
            omitting it, answered with five 401s and no diagnosable cause.
          schema:
            type: string
        - name: Content-Type
          in: header
          required: true
          schema:
            type: string
            const: application/json
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: ./delivery.schema.json
      responses:
        "200":
          description: |
            Delivery accepted. Any 2xx is success; the platform records `delivered` and stops
            retrying. The body is ignored.
        "4XX":
          description: |
            Permanent rejection. The platform records the attempt with its reason and does NOT
            retry a 4xx other than 408/429.
        "5XX":
          description: |
            Transient failure. Retried on the published schedule until the budget is exhausted, then
            recorded `exhausted`.

components:
  securitySchemes:
    sessionCookie:
      type: apiKey
      in: cookie
      name: aiconnect_session
      description: |
        httpOnly, SameSite server-side session cookie, set by the OTP verify endpoint. Not
        readable by JavaScript, so it cannot be exfiltrated by injected script.

        CROSS-ORIGIN PRECONDITION. A browser client served from a DIFFERENT origin than the API
        must send credentialed requests (`credentials: "include"` in fetch), and the deployment
        must be configured to allow that origin: the response needs
        `Access-Control-Allow-Credentials: true` and an `Access-Control-Allow-Origin` echoing the
        caller's exact origin. A wildcard `*` cannot be used, because browsers reject it whenever
        credentials are allowed — and this scheme IS a credential.

        Without that configuration the browser's preflight `OPTIONS` fails and the real request is
        never sent, so the client sees a network-level error with no corresponding server log
        entry — indistinguishable from the API being down. This document previously described a
        cookie-authenticated browser API without stating that precondition, and the first real
        frontend join hit exactly that failure (J1, 2026-08-04).

        The allow-list ITSELF is deployment configuration, not part of this contract: changing it
        from a development origin to a production one requires no client code change, so it varies
        per environment the same way `servers:` above does. A client author needs to know the
        precondition exists; the values belong to whoever runs the deployment.
    cliToken:
      type: http
      scheme: bearer
      description: |
        A user's CLI token, minted at `POST /v1/auth/cli-tokens` from an already-signed-in browser
        and pasted into a terminal. Sent as `Authorization: Bearer <token>`.

        WHY THIS EXISTS ALONGSIDE `sessionCookie` RATHER THAN REPLACING IT. The session cookie is
        `HttpOnly`, so no script can read it and no terminal can copy it — correct for a browser,
        useless for a CLI. This scheme is the CLI's equivalent, and the two are permanent siblings:
        the token path needs a browser to mint it, so it CANNOT be used on a server, in a container,
        or in CI. Email OTP remains the only headless route. Neither replaces the other.

        🔴 A PASTED TOKEN IS PLAINTEXT AT REST, in a config file, on a laptop — exactly the exposure
        `HttpOnly` avoids. That is why the contract bounds its lifetime (`expiresAt`), makes it
        revocable (`DELETE`), and makes it VISIBLE (`lastUsedAt`, `lastUsedIp`): a stolen credential
        nobody can see is a credential nobody revokes.

        ⚠️ SCOPE IS OPEN, NOT DECIDED. Measured: one `RequireSession` guard wraps every module route
        and no per-request permission model exists, so a session-equivalent token today unlocks the
        whole console surface. Narrowing that is a user decision (S10-B2), and this description will
        be wrong the moment it is made — it says what is true now rather than what would be nice.
    agentSignature:
      type: apiKey
      in: header
      name: X-Platform-Signature
      description: |
        HMAC-SHA256 over `<unix timestamp>.<raw request body>`, keyed by the agent's credential
        secret. Compared in constant time; timestamps outside ±5 minutes are rejected.

        THE KEY IS THE CREDENTIAL STRING'S OWN BYTES, NOT A DECODED FORM OF IT. `POST
        /v1/agents/{agentId}/credentials` returns `secret` as a base64url-encoded string, but that
        encoding is only how the secret travels over JSON — the HMAC key is the ASCII bytes of the
        string exactly as printed, never the bytes you get from base64-decoding it. Base64-decoding
        first produces a DIFFERENT key of a different length and every signature you compute with it
        will be rejected as invalid, indistinguishable from a wrong secret entirely (DEBT 29).

        ROTATION IS A TWO-ENDED OBLIGATION — read this before implementing verification.

        Credentials rotate with an overlap: `POST /v1/agents/{agentId}/credentials` with
        `retireCurrentAfter` keeps the outgoing secret valid until that instant, so for a window
        MORE THAN ONE secret is currently valid for the same agent. During that window the platform
        signs with the OLDEST still-valid credential, giving you time to deploy the new one.

        Your webhook endpoint MUST therefore try EVERY currently-valid secret before rejecting a
        request. This header carries no key identifier, so there is no way to tell which credential
        signed a given delivery — try-all is the only correct implementation, not an optimization
        you may skip. An agent that verifies against a single secret drops every delivery signed
        with the other one for the whole overlap, and the failure looks like an invalid signature
        rather than a configuration problem.

        Two properties to preserve while doing it: return ONE indistinguishable error for every
        rejection (a caller must not learn which secret failed, or how many were tried — that
        discloses whether a rotation is in progress), and do not return early on the first match,
        because the time taken would then depend on WHICH credential verified.

        The platform's own first-party agent implements exactly this and nothing more
        (`agents/echo`), so the rule is demonstrably implementable through this contract alone.

  parameters:
    AgentId:
      name: agentId
      in: path
      required: true
      description: Identifier of the agent, prefixed `agt_`.
      schema:
        type: string
        pattern: ^agt_[0-9A-HJKMNP-TV-Z]{26}$

  responses:
    BadRequest:
      description: The request was malformed or failed validation.
      content:
        application/json:
          schema:
            $ref: ./common.schema.json#/$defs/error
    Unauthenticated:
      description: No valid session. Sign in first.
      content:
        application/json:
          schema:
            $ref: ./common.schema.json#/$defs/error
    RateLimited:
      description: |
        Too many requests. Rate limits are keyed per user AND per agent in separate buckets, so a
        noisy agent cannot starve human traffic.
      content:
        application/json:
          schema:
            $ref: ./common.schema.json#/$defs/error
    SchemaViolation:
      description: |
        The request body does not match the endpoint's schema.

        ALL violations are listed, not just the first. Measured: a manifest sent with both an
        invalid `webhook_url` and a stray top-level field returned BOTH in one `hint`. This matters
        to an agent author because a first-error-only response turns one bad request into a
        round-trip per mistake — and it cost real time in Sprint 2, where reading only the first of
        two listed violations produced a wrong conclusion about `httpsUrl` being a hard wall.
      content:
        application/json:
          schema:
            $ref: ./common.schema.json#/$defs/error

    ManifestInvalid:
      description: |
        The manifest failed schema validation. The error names the offending field so it can be
        corrected without guessing.
      content:
        application/json:
          schema:
            $ref: ./common.schema.json#/$defs/error
