openapi: 3.1.0
info:
  title: Cairn API
  version: 0.1.0
  description: |
    Cairn platform API — SINGLE SOURCE OF TRUTH for all HTTP contracts.

    Rules (see repo CLAUDE.md):
    - Every payload change lands here FIRST, in the same PR as implementation.
    - Clients consume generated types only (TS via openapi-typescript, PHP DTOs via spatie/laravel-data generator).
    - Client-initiated POST endpoints accept an `Idempotency-Key` header: a retry
      with the same key within 24h replays the original success response instead
      of re-executing; a concurrent duplicate returns 409 `idempotency_conflict`.
      Deliberate exceptions (no header declared, enforced by the
      cairn-idempotency-key Spectral rule): webhook receivers under `/hooks/*`
      (senders carry their own event ids), `/beacon` (navigator.sendBeacon cannot
      set headers), `/widget/broadcasting/auth` (non-mutating handshake), the
      federation surfaces `/sso/saml/*` and `/scim/v2/*` (ATH-260 — the callers
      are a customer's IdP and its SCIM connector, neither of which is an Cairn
      client; for the SAML ACS a replayed success is precisely what the
      consumed-assertion store must refuse, and SCIM's own idempotency is the
      RFC 7644 §3.3 `userName` uniqueness 409), and the
      token-minting `/auth/token`, `/widget/session` + `/oauth/token` (responses
      carry credentials and are never replay-cached). `/oauth/token` has a second
      reason: an authorization code is single-use and a refresh token rotates, so
      replaying a cached 200 for a repeated request would hand back a credential
      the reuse-detection path exists to revoke. Multipart upload endpoints accept
      the header but implement chunk-level idempotency instead of response replay.
    - Pagination is cursor-based everywhere (`cursor`, `limit`, response `meta.next_cursor`).
    - Errors always use the Error envelope below.

    ## Public vs internal (ATH-250, Doc 06 §4)

    This document describes ONE `/v1` namespace served by two kinds of
    credential, and they are not interchangeable:

    - **Internal** operations back Cairn's own portal and clients. They
      require a session or a user PAT. A workspace API key cannot call
      them and gets 401.
    - **Public** operations are the supported integration surface. They
      are tagged `PublicApi` IN ADDITION to their resource tag, they are
      authenticated by a workspace API key, and each one names the
      ability the key must carry. A key without that ability gets 403.

    The `PublicApi` tag is the only marker; a path prefix is NOT one
    (both kinds live under `/v1`). Server-side the boundary is the
    `public-api:<ability>` middleware, and a route-enumeration test
    asserts the two agree in both directions — a public operation with
    no such route fails the build, and a route on the public surface
    that this spec does not tag `PublicApi` fails it too.

    Abilities are `<resource>:<read|write>`, derived from the RBAC
    permission catalog (`ApiAbilities`). `write` does not imply `read`.
    A key may hold `*` for the whole surface.

    ## Versioning and compatibility promise

    `/v1` is a stable major version. Within it we make only ADDITIVE
    changes: new endpoints, new OPTIONAL request fields, new response
    fields, and new members of a response enum.

    In exchange, clients MUST ignore response fields they do not
    recognise and MUST tolerate unseen enum values. A client that
    rejects unknown fields is not compatible with this promise.

    Anything else — removing or renaming a field, making an optional
    field required, removing an enum member, changing the status code
    for an existing condition, or changing what an existing field means
    — ships as `/v2`, and `/v1` keeps working.

    Retirement: an operation being withdrawn is marked `deprecated: true`
    here AND returns RFC 8594 `Deprecation:` and `Sunset:` headers, with
    at least 180 days between the two dates. A whole major version gets
    at least 365 days' notice. Every public response carries
    `Cairn-Api-Version: v1`.
  contact:
    name: Cairn Platform
servers:
  - url: https://app.cairn.how/api/v1
    description: Production API
  - url: http://localhost:8000/api/v1
    description: Local

security:
  - bearerAuth: []

tags:
  - name: PublicApi
    description: >
      ATH-250, Doc 06 §4 — the SUPPORTED integration surface. An
      operation carrying this tag (always alongside its resource tag)
      is callable with a workspace API key and names the ability that
      key must hold; everything in this document without it is
      internal and rejects key authentication outright. This tag is
      the reader's answer to "may I build on this?", and it is
      load-bearing rather than decorative: a Pest test enumerates the
      registered routes and fails the build if the set of
      `PublicApi`-tagged operations and the set of routes behind the
      `public-api` middleware ever disagree. See `info.description`
      for the versioning promise that comes with the tag.
  - name: Auth
  - name: OAuthApps
    description: >
      ATH-252 — the OAuth app platform (Doc 06 §4). A third-party
      developer registers an app against their own workspace; a workspace
      ADMIN elsewhere grants that app scoped access to THEIR workspace
      through an authorization-code flow with mandatory PKCE.


      Scopes are not a new vocabulary. An OAuth scope IS an ATH-250
      ability (`ApiAbilities`), so an app's access is described in the
      same words as an API key's and enforced by the same
      `public-api:<ability>` middleware. A second, drifting scope list
      was the main design risk of this ticket and it was refused.


      Two grant ceilings apply to every consent, and the SMALLER of them
      wins. The granted set may never exceed the app's registered
      request, and it may never exceed what the granting user could do
      themselves — an admin cannot delegate an ability they do not hold.


      `/oauth/token` is the authorization server, not a resource: it is
      the one path here that carries no ability and no bearer token,
      because it is what MINTS them. It authenticates the client by
      `client_id` + `client_secret` + PKCE verifier and answers nothing
      to an API key. There is deliberately NO introspection endpoint —
      the token response already carries `scope` and `workspace_id`, so
      an ability-free authenticated GET would add a hole in ATH-250's
      route-enumeration invariant and buy the app nothing it was not
      handed at issue time.
  - name: Workspaces
  - name: Members
  - name: Invitations
  - name: ApiKeys
  - name: Billing
  - name: Captures
  - name: Guides
  - name: Media
  - name: Admin
  - name: Contacts
  - name: Segments
    description: >
      Saved contact segments (Doc 04 §6, ATH-210): a boolean filter tree
      over contact attributes, activity, and channel facts. Membership is
      materialized (nightly recount + incremental per-contact refresh);
      dynamic previews evaluate live.
  - name: Campaigns
    description: >
      One-shot email campaigns to a segment (Doc 04 §6, ATH-211). The
      audience is a Segment's MATERIALIZED membership, never a live
      filter run. Sending fans out one queued send per contact through
      the ordinary email channel pipeline, and a per-recipient ledger
      row (unique per campaign+contact, claimed before the send) is what
      makes a redelivered job a no-op — a campaign never double-sends.
      Suppressed contacts (unsubscribed or hard-bounced) are excluded at
      fan-out AND re-checked at send time.
  - name: Knowledge
    description: >
      RAG ingestion (Doc 05 §3): the sources the AI agent retrieves
      from — crawls, uploads, and the agent's own settings. Distinct
      from KnowledgeBase, which is the human-facing content.
  - name: KnowledgeBase
    description: >
      Help-centre content (Doc 03 §3–4): collections, articles, pages,
      and KB search. Distinct from Knowledge, which is the AI retrieval
      layer.

      ATH-224 — `getHelpCenterSearchAnswer` is the one endpoint here
      that is NOT served on the API hosts above. It is registered under
      the same `api/v1` prefix, but domain-scoped to the help-centre
      hosts, because its tenant comes from that host alone — never from
      a bearer token, a workspace id, or a widget key.
  - name: Inbox
    description: Conversations, messages, routing, and the email channel (Doc 04 §4–6).
  - name: Integrations
    description: >
      Third-party integrations (Doc 06 §6) — the first is Slack
      (ATH-254), bi-directional per the catalog table: new customer
      messages are announced in a channel, and a staff reply in that
      Slack thread reaches the visitor.


      NOT a channel. The omnichannel adapters under Inbox model a place
      CUSTOMERS talk to a workspace; Slack here is a place the
      workspace's own STAFF talk about a conversation that arrived
      somewhere else. That is why a Slack reply is written as an
      ordinary outbound message authored by a mapped Cairn user rather
      than as an inbound one, and why an unmapped Slack user cannot post
      at all.
  - name: Widget
    description: >
      The public widget surface (Doc 04 §2). These endpoints carry no
      session: a widget key or signed visitor token names the tenant.
  - name: Realtime
    description: Reverb channel authorisation and the missed-event resume protocol (Doc 04 §3).
  - name: Exports
    description: Guide/article export jobs — PDF, HTML, Markdown, DOCX (Doc 03 §6).
  - name: Analytics
  - name: Retrieval
    description: >
      The server-to-server knowledge surface (Doc 06 §7): hybrid
      retrieval over the workspace corpus plus the document reads
      behind a hit. Authenticated by a workspace API key — the key IS
      the principal and names the tenant, so there is no workspace in
      these paths. Consumed by the Cairn MCP server
      (tools/mcp-server) and "enterprise search API" integrations.
  - name: Tools
    description: >
      Per-workspace agent tools (Doc 05 §5, ATH-202): the tool registry
      surface plus the HTTP tool builder. A `kind=http` tool is a
      workspace-authored outbound call — base URL, method, templated
      path/query/headers/body, auth injected from the encrypted vault,
      and a response mapping that trims the payload before an agent
      sees it. Every such call passes an SSRF guard that refuses
      private, loopback, link-local, and cloud-metadata targets after
      DNS resolution and re-checks each redirect hop. Requires
      `integrations.manage`; the builder is gated on
      `feature.ai.tool_builder`.
  - name: Automation
    description: >
      The "do it for me" execution audit (Doc 07 §2.2, PA-220): the
      agent-driven runtime executes an executable guide's steps one at a
      time on the customer's own app, under a consent HUD, and every run
      and every executed step is logged. READ-ONLY here — the rows are
      written in-process by the execution engine (PA-218), never over
      HTTP. Steps record WHICH selector resolved and THAT an action
      happened, never a typed value (CLAUDE.md rule 3). Requires
      `ai.view_runs` and is dark below `feature.automation.runs`.
  - name: Guidance
    description: >
      Context-aware proactive guidance (Doc 07 §4, E32): the
      page↔knowledge map that links URL patterns to the guides relevant
      there. Manual pins (curated: "pin this guide to /settings/billing",
      optionally to one element via a selector) plus the automatic
      associations derived from the per-step URLs guides carry from
      capture. The map query is the read the nudge engine (PA-229) and
      the employee Sidekick (PA-230) build on. Dark below
      `feature.guidance.page_map`.
  - name: MCP
    description: >
      External MCP servers (Doc 05 §5, ATH-203) — Cairn as an MCP
      CLIENT, the opposite direction to the Cairn MCP server under
      Search. A workspace attaches someone else's MCP server; a
      discover call performs the handshake and `tools/list` and
      materialises what it finds as ordinary `kind=mcp` tools, so
      permission rules, the agent_actions audit, workflow action nodes
      and the agent runtime all apply unchanged. Only the remote
      Streamable HTTP transport is supported; stdio (which would mean
      running a local process per workspace) is deliberately not, and
      every request passes the same SSRF guard the HTTP tool builder
      uses. Discovered tools are ALWAYS created disabled and get no
      agent-tier permission rule — a third party describes its own
      tools and those descriptions reach an LLM prompt, so enabling one
      is a human decision every time. Requires `integrations.manage`
      and the `feature.ai.mcp_client` flag.
  - name: Workflows
    description: >
      No-code workflow builder (Doc 05 §6, ATH-191): CRUD over authored
      node graphs, server-side graph validation, and draft/publish.
      The runtime (ATH-190) triggers only PUBLISHED workflows; the
      editor writes a DRAFT graph and `publish` promotes it to the
      active graph the runtime reads. Requires `workflows.manage`; the
      builder itself is gated on `feature.workflows.builder` (Growth+).
  - name: Ticketing
    description: >
      Tickets + the customer portal (Doc 04 §7, ATH-214). A ticket is
      NOT a second entity: per the doc, "tickets = conversations with
      kind=ticket, plus reference number, priority, SLA timers, custom
      ticket fields, portal visibility". These paths therefore read and
      write the SAME conversations rows the Inbox tag serves, and the
      thread is ordinary messages — reply, assign, and state transitions
      all reuse the Inbox endpoints rather than duplicating them here.
      Requires `tickets.manage`; gated on `feature.support.ticketing`
      (Scale+), which 404s below the plan. The end-customer portal is a
      server-rendered surface on the help-center host, not an API — its
      identity is the ATH-096 JWT handshake session resolved to a
      Contact, so it carries no bearer token and appears in no path here.
  - name: StatusPage
    description: >
      Public status page, components, incidents, and uptime monitors
      (Doc 04 §8, ATH-215). These paths are the ADMIN surface only —
      the status page a customer reads is a server-rendered, anonymous,
      full-page-cached surface on the help-center host (ATH-092/093),
      so it carries no bearer token and appears in no path here, exactly
      like the ATH-214 customer portal. Requires `settings.manage`;
      gated on `feature.support.status_page` (Scale+), which 404s below
      the plan. Monitors make outbound HTTP to workspace-supplied URLs
      and therefore run through ATH-202's SafeHttpClient/OutboundUrlGuard
      on every check and every redirect hop; a URL that fails the guard's
      DNS-free checks is rejected at authoring time with 422.

  - name: Webhooks
    description: >
      Outbound event delivery to workspace-registered HTTP endpoints
      (Doc 06 §5, ATH-251) — the OPPOSITE direction to the `/hooks/*`
      paths, which are inbound receivers for Stripe, Twilio and Meta.
      Requires `webhooks.manage`; gated on `feature.api.webhooks`, which
      404s when off. An endpoint is a workspace-supplied URL that this
      platform POSTs to on a retry schedule with no human in the loop,
      so every URL runs through ATH-202's SafeHttpClient and
      OutboundUrlGuard — the DNS-free half at authoring time (422 on the
      `url` field) and the resolving half on every single attempt, which
      is what catches a host that is public today and private tomorrow.
      Bodies carry an HMAC-SHA256 signature over a timestamp and the raw
      body; the verification recipe, the header format and the replay
      window are documented for customers in `docs/webhooks.md`.
  - name: Sso
    description: >
      ATH-260, Doc 01 §4 — Cairn as a SAML 2.0 SERVICE PROVIDER. A
      workspace federates login to its own IdP (Okta, Entra, Ping,
      Google Workspace). This is the mirror image of `OAuthApps`: there
      Cairn is the authorization server a third party trusts, here
      Cairn is the relying party that trusts a customer's IdP.


      Assertion handling is NOT hand-rolled. XML canonicalisation and
      signature verification run through `onelogin/php-saml`
      (SAML-Toolkits, MIT), because hand-written SAML is a documented
      source of critical authentication bypasses and every one of them
      is a bug in code that looked correct.


      WORKSPACE RESOLUTION IS THE TENANCY BOUNDARY, and it is decided
      BEFORE any XML is parsed. Every workspace gets its own ACS URL and
      its own SP entity ID, both containing the workspace id, so the
      tenant comes from the ROUTE — never from a claim inside the
      assertion, and never from the email domain. An email domain is a
      DNS fact about a mail server, not a statement about which Cairn
      workspace someone belongs to, and two workspaces may legitimately
      share one.


      On top of the route binding, FIVE independent checks must all
      agree with the addressed workspace's stored configuration before
      anyone is logged in: the signature must verify against THAT
      workspace's IdP certificate; the assertion `Issuer` must equal
      THAT workspace's configured IdP entity id;
      `SubjectConfirmationData/@Recipient` must name the ACS actually
      being called; `Destination` must name THAT workspace's ACS,
      matched strictly rather than by prefix; and the `Audience`
      restriction must equal THAT workspace's SP entity id, which is
      derived from the workspace id and so is unique per tenant.


      Two tenants with different IdPs are separated by the signature
      alone. Two tenants sharing ONE IdP and ONE certificate are
      separated three times over — recipient, destination and audience
      each refuse independently — so no single test can attribute that
      refusal to one of them. Two tests split the matrix instead: an
      assertion addressed to this workspace but scoped to another
      isolates the audience, and one scoped to this workspace but
      addressed to another isolates the endpoint binding.


      That count is five rather than four because breaking the guards
      one at a time found a check that reading the settings had missed:
      `Recipient` is validated in php-saml's SubjectConfirmation loop
      and is NOT governed by `relaxDestinationValidation`.
  - name: Scim
    description: >
      ATH-260, Doc 01 §4 — SCIM 2.0 (RFC 7643 / RFC 7644) user and group
      provisioning. The customer's IdP is the system of record for who
      may enter the workspace; Cairn is the downstream target.


      THESE PAYLOADS ARE NOT CAIRN-SHAPED AND DELIBERATELY SO. SCIM
      defines its own resource schemas, its own `ListResponse` envelope,
      its own PATCH operation format, its own `application/scim+json`
      media type and its own error body
      (`urn:ietf:params:scim:api:messages:2.0:Error`). Off-the-shelf IdP
      SCIM clients are not configurable — Okta and Entra send what the
      RFC says and parse what the RFC says. Reshaping any of it into the
      house cursor-pagination and `error.code` envelope would produce an
      endpoint that is elegant here and non-functional in every product
      that would actually call it. This mirrors the exception already
      granted to `/oauth/token` for RFC 6749 §5.2 errors, and for the
      same reason.


      What is NOT re-invented is the permission vocabulary. A SCIM Group
      maps onto a role from the existing RBAC catalog
      (`PermissionCatalog::roles()`) — there is no parallel SCIM role
      list. The mapping is capped at `admin`: an IdP group may never
      confer `owner`, because `owner` carries `billing.manage` and
      `workspace.delete`, and ownership is a commercial relationship
      rather than a directory fact. That ceiling is the same reasoning
      ATH-252 applied to OAuth consent.


      DEPROVISIONING REVOKES, it does not merely flag. See
      `deprovisionScimUser` for the exact scope.
paths:
  # ── Auth ────────────────────────────────────────────────────────────────
  /auth/token:
    post:
      tags: [Auth]
      operationId: createToken
      summary: Exchange credentials for a personal access token (PAT)
      description: >
        No session: the credential in the request (widget key,
        invitation token, or webhook signature) names the tenant and is
        verified server-side.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, password, device_name]
              properties:
                email: { type: string, format: email }
                password: { type: string, format: password }
                device_name: { type: string, maxLength: 100 }
                totp_code: { type: string, description: Required when 2FA is enabled }
      responses:
        "201":
          description: Token created
          content:
            application/json:
              schema:
                type: object
                required: [token]
                properties:
                  token: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /auth/me:
    get:
      tags: [Auth]
      operationId: getCurrentUser
      summary: Current authenticated user
      description: When the session is an impersonation (ATH-034), `impersonation` is present — clients MUST render the banner from it.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/User" }
                  impersonation:
                    type: object
                    description: Present only during an impersonated session
                    required: [impersonated_by, expires_at]
                    properties:
                      impersonated_by: { $ref: "#/components/schemas/Ulid" }
                      impersonator_name: { type: [string, "null"] }
                      expires_at: { type: string, format: date-time }
        "401": { $ref: "#/components/responses/Unauthorized" }

  # ── Workspaces ──────────────────────────────────────────────────────────
  /workspaces:
    get:
      tags: [Workspaces]
      operationId: listWorkspaces
      summary: List workspaces the caller belongs to
      description: >
        Authenticated (session or API key); any member of the workspace
        may call it.
      parameters:
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/PaginatedList"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/Workspace" }
    post:
      tags: [Workspaces]
      operationId: createWorkspace
      summary: Create a workspace
      description: >
        ATH-018 — authenticated; the caller becomes the new workspace's
        owner. Pre-context (no workspace to authorize against yet).
        Idempotency-Key replays the same workspace for 24h.
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, minLength: 2, maxLength: 80 }
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Workspace" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /workspaces/{workspaceId}:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    get:
      tags: [Workspaces]
      operationId: getWorkspace
      summary: Read a single workspace
      description: >
        ATH-018 — any member of the workspace; membership is enforced by
        workspace.context before the handler runs.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Workspace" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Workspaces]
      operationId: updateWorkspace
      summary: Update a workspace's name or settings (settings.manage)
      description: >
        ATH-018 — requires settings.manage. `settings` MERGES rather than
        replaces, so a partial update never clobbers keys it did not send.


        PA-270 — that merge is DEEP, at every level. Objects are merged
        key by key: `{"voice":{"recording_enabled":false}}` changes that
        one key and leaves `voice.recording_announcement` and
        `voice.greeting` exactly as they were. (Until PA-270 it merged
        only the top level, so the same request deleted the rest of the
        `voice` object silently.)


        ARRAYS ARE REPLACED WHOLESALE, never merged element by element —
        that is what lets a list SHRINK. Send the list you want stored;
        send `[]` to empty it.


        AN EMPTY OBJECT NAMES NO KEYS, so `{"voice":{}}` changes nothing
        under `voice` (and `{"settings":{}}` changes nothing at all).


        TO REMOVE A SETTING, SEND `null`. The key is stored as null and
        every consumer reads a stored null exactly as it reads an absent
        key — the platform default applies. `{"voice":{"greeting":null}}`
        drops the custom greeting and keeps its siblings;
        `{"voice":null}` retires the whole area.


        PA-266 — `settings.automation.sensitive_keywords` is the
        workspace's own addition to the sensitive-page rail (Doc 07 §2.2,
        "no execution on payment/password pages"). Each entry is a
        substring matched case-insensitively against the host, path, query
        and fragment of any URL the "do it for me" engine is asked to act
        on; a match refuses the run. Use it to name the money and
        credential pages of your own product that the built-in English
        list misses — `copay`, `nomina`, `paye`, `wire-transfer`.


        This list can only ever WIDEN what is refused. The built-in
        keywords (checkout, payment, billing, login, password, …) are
        always enforced on top of it and cannot be removed, overridden or
        allow-listed by any value stored here.


        Stored canonically: entries are lowercased, trimmed and
        de-duplicated on write. Rejected with 422: a non-array, a
        non-string entry, an entry shorter than 2 or longer than 64
        characters (a 1-character keyword would match nearly every URL and
        silently disable the engine), or more than 50 entries.


        PA-268 — `settings.tools.store_action_payloads` decides whether
        the tool audit (`agent_actions`, Doc 05 §5) keeps the ARGUMENTS a
        tool was called with and the RESULT it returned. Default `true`:
        every existing workspace keeps the behaviour it has today. Set it
        to `false` and those two payloads are never written — the
        invocation record itself (who asked, which tool, what the
        permission engine decided, who approved, how it ended, how long
        it took, when) is written in full either way, and the row is
        stamped `payload_scrub_reason: workspace_opt_out` so a reader can
        tell "this workspace does not store payloads" from "the tool
        returned nothing".


        It covers BOTH arguments and result, deliberately: an argument
        carries the same customer data a result does (the address in a
        create-order call), and it is the same pair the retention sweep
        drops. A control that kept one of them would be a privacy switch
        with a hole in it.


        Rejected with 422: anything that is not a boolean — `"true"`,
        `"off"`, `7`, an object. `true`/`false`, `1`/`0` and `"1"`/`"0"`
        are accepted and STORED AS A REAL BOOLEAN, because the reader
        compares strictly (`=== false`) and a stored `"0"` would
        otherwise be a setting you can save and cannot trust. Send `null`
        to retire the key back to the default.


        PA-279 — `settings.capture.strict_originals` makes newly uploaded
        screenshot originals application-encrypted before object storage
        and queues encryption of existing originals. Enabling requires the
        Enterprise enforced-redaction entitlement and the default-off
        `feature.capture.strict_originals` rollout flag. Once enabled, a
        later flag rollback or plan downgrade does not silently weaken the
        stored privacy choice; send `false` explicitly to stop encrypting
        future originals. Owner/admin only through this route's existing
        `settings.manage` gate.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                settings:
                  type: object
                  additionalProperties: true
                  properties:
                    automation:
                      type: object
                      additionalProperties: true
                      properties:
                        sensitive_keywords:
                          type: array
                          maxItems: 50
                          items: { type: string, minLength: 2, maxLength: 64 }
                          description: >
                            PA-266 — extra sensitive-page substrings for
                            this workspace. Widens the built-in block list;
                            can never narrow it. Send `[]` to clear.
                          examples: [["copay", "wire-transfer"]]
                    tools:
                      type: object
                      additionalProperties: true
                      properties:
                        store_action_payloads:
                          type: boolean
                          default: true
                          description: >
                            PA-268 — keep the arguments and result of
                            every tool invocation in the `agent_actions`
                            audit. `false` writes the invocation record
                            without them, marked
                            `payload_scrub_reason: workspace_opt_out`.
                            Covers arguments AND result. Default `true`.
                    capture:
                      type: object
                      additionalProperties: true
                      properties:
                        strict_originals:
                          type: boolean
                          default: false
                          description: >
                            PA-279 — application-encrypt screenshot
                            originals and queue encryption of existing
                            originals. Enterprise + rollout-flag gated on
                            enable; send false to disable future encryption.
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Workspace" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ValidationError" }

  # ── Members & Invitations ───────────────────────────────────────────────
  /workspaces/{workspaceId}/members:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    get:
      tags: [Members]
      operationId: listMembers
      description: >
        Requires `members.manage`. Scoped to the workspace in the path —
        a member of another workspace gets 403, never another tenant’s
        rows.
      parameters:
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/PaginatedList"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/Member" }

  /workspaces/{workspaceId}/members/{memberId}:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - name: memberId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    patch:
      tags: [Members]
      operationId: updateMember
      summary: Change a member's role or permission overrides
      description: >
        Requires `members.manage`. Scoped to the workspace in the path —
        a member of another workspace gets 403, never another tenant’s
        rows.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                role: { $ref: "#/components/schemas/Role" }
                permissions:
                  type: object
                  description: Per-member grants/revokes on top of the role bundle
                  properties:
                    grant: { type: array, items: { type: string } }
                    revoke: { type: array, items: { type: string } }
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Member" }
        "403": { $ref: "#/components/responses/Forbidden" }
    delete:
      tags: [Members]
      operationId: removeMember
      description: >
        Requires `members.manage`. Scoped to the workspace in the path —
        a member of another workspace gets 403, never another tenant’s
        rows.
      responses:
        "204": { description: Removed }
        "403": { $ref: "#/components/responses/Forbidden" }

  /workspaces/{workspaceId}/invitations:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    post:
      tags: [Invitations]
      operationId: createInvitation
      description: >
        Requires `members.manage`. Scoped to the workspace in the path —
        a member of another workspace gets 403, never another tenant’s
        rows.
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, role]
              properties:
                email: { type: string, format: email }
                role: { $ref: "#/components/schemas/Role" }
      responses:
        "201":
          description: Invitation sent
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Invitation" }
        "402":
          description: Seat allowance exceeded — add seats first
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /workspaces/{workspaceId}/onboarding:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    get:
      tags: [Workspaces]
      operationId: getOnboardingChecklist
      summary: Activation checklist derived from the domain-event spine
      description: Steps complete when their domain event fires for the workspace (Doc 01 §7, ATH-052).
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/OnboardingChecklist" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /workspaces/{workspaceId}/onboarding/dismiss:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Workspaces]
      operationId: dismissOnboardingChecklist
      summary: Hide the checklist for the workspace
      description: >
        Requires `settings.manage`. Scoped to the workspace in the path
        — a member of another workspace gets 403, never another tenant’s
        rows.
      responses:
        "200":
          description: Dismissed
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/OnboardingChecklist" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /workspaces/{workspaceId}/brand-profiles:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    get:
      tags: [Workspaces]
      operationId: listBrandProfiles
      summary: The workspace's brand kits
      description: ATH-083 — logo, colors, marker style, font; applied at render/export time (Doc 03 §2).
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/BrandProfile" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Workspaces]
      operationId: createBrandProfile
      summary: Create a brand kit
      description: Setting is_default clears the previous default (ATH-083).
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/BrandProfileInput" }
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/BrandProfile" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /workspaces/{workspaceId}/brand-profiles/{brandProfileId}:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - name: brandProfileId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    patch:
      tags: [Workspaces]
      operationId: updateBrandProfile
      summary: Update a brand kit — restyles referencing guides without reprocessing
      description: >
        Requires `settings.manage`. Scoped to the workspace in the path
        — a member of another workspace gets 403, never another tenant’s
        rows.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/BrandProfileInput" }
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/BrandProfile" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }
    delete:
      tags: [Workspaces]
      operationId: deleteBrandProfile
      summary: Delete a brand kit — referencing guides fall back to the workspace default
      description: >
        Requires `settings.manage`. Scoped to the workspace in the path
        — a member of another workspace gets 403, never another tenant’s
        rows.
      responses:
        "204": { description: Deleted }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /workspaces/{workspaceId}/collections:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    get:
      tags: [KnowledgeBase]
      operationId: listCollections
      summary: KB categories (one level of nesting), ordered
      description: >
        Requires `articles.manage`. Scoped to the workspace in the path
        — a member of another workspace gets 403, never another tenant’s
        rows.
      responses:
        "200":
          description: Roots with children
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Collection" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [KnowledgeBase]
      operationId: createCollection
      summary: Create a category (children cannot themselves parent)
      description: >
        Requires `articles.manage`. Scoped to the workspace in the path
        — a member of another workspace gets 403, never another tenant’s
        rows.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CollectionInput" }
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/Collection" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /workspaces/{workspaceId}/collections/{collectionId}:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - name: collectionId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    patch:
      tags: [KnowledgeBase]
      operationId: updateCollection
      summary: Update a category
      description: >
        Requires `articles.manage`. Scoped to the workspace in the path
        — a member of another workspace gets 403, never another tenant’s
        rows.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CollectionInput" }
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/Collection" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }
    delete:
      tags: [KnowledgeBase]
      operationId: deleteCollection
      summary: Delete a category (articles fall back to uncategorized)
      description: >
        Requires `articles.manage`. Scoped to the workspace in the path
        — a member of another workspace gets 403, never another tenant’s
        rows.
      responses:
        "204": { description: Deleted }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /workspaces/{workspaceId}/articles:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    get:
      tags: [KnowledgeBase]
      operationId: listArticles
      summary: Articles (filter by collection_id / status); bodies omitted
      description: >
        Requires `articles.manage`. Scoped to the workspace in the path
        — a member of another workspace gets 403, never another tenant’s
        rows.
      parameters:
        - { name: collection_id, in: query, schema: { type: string } }
        - { name: status, in: query, schema: { type: string, enum: [draft, in_review, published, archived] } }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Article" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [KnowledgeBase]
      operationId: createArticle
      summary: Create an article (body sanitized server-side)
      description: >
        Requires `articles.manage`. Scoped to the workspace in the path
        — a member of another workspace gets 403, never another tenant’s
        rows.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ArticleInput" }
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/Article" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /workspaces/{workspaceId}/articles/{articleId}:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - name: articleId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    get:
      tags: [KnowledgeBase]
      operationId: getArticle
      summary: Full article incl. body + divergence indicator
      description: >
        Requires `articles.manage`. Scoped to the workspace in the path
        — a member of another workspace gets 403, never another tenant’s
        rows.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/Article" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [KnowledgeBase]
      operationId: updateArticle
      summary: Update an article (body sanitized server-side)
      description: >
        Requires `articles.manage`. Scoped to the workspace in the path
        — a member of another workspace gets 403, never another tenant’s
        rows.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ArticleInput" }
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/Article" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }
    delete:
      tags: [KnowledgeBase]
      operationId: deleteArticle
      summary: Delete an article
      description: >
        Requires `articles.manage`. Scoped to the workspace in the path
        — a member of another workspace gets 403, never another tenant’s
        rows.
      responses:
        "204": { description: Deleted }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /workspaces/{workspaceId}/articles/{articleId}/status:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - name: articleId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [KnowledgeBase]
      operationId: setArticleStatus
      summary: Workflow transition — publishing requires approval permission
      description: >
        ATH-090 — "creator submits, content_manager approves" (Doc 03 §4):
        transitioning INTO published requires the content-publish permission
        and stamps approved_by + published_at.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [status]
              properties:
                status: { type: string, enum: [draft, in_review, published, archived] }
      responses:
        "200":
          description: Transitioned
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/Article" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /workspaces/{workspaceId}/articles/{articleId}/translations:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - name: articleId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    get:
      tags: [KnowledgeBase]
      operationId: listArticleTranslations
      summary: Per-locale variants of an article
      description: >
        ATH-242 — the locale variants of one article (Doc 03 §5
        "Multilingual"). Requires `articles.manage` and the
        `feature.content.multilingual` flag; below the flag every route
        in this group is 404. Scoped to the workspace in the path — a
        member of another workspace gets 403 and never another tenant's
        rows. Each variant carries its OWN publish state: a draft
        translation does not serve even when the source article is live.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/ArticleTranslation" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /workspaces/{workspaceId}/articles/{articleId}/translations/{locale}:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - name: articleId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
      - name: locale
        in: path
        required: true
        description: >
          A BCP-47 locale tag restricted to an ISO 639-1 language with an
          optional script and/or region — `de`, `pt-BR`, `zh-Hans`.
          Accepted in any casing and canonicalized on write. The language
          allowlist is closed because the public site serves non-default
          locales under a path prefix that shares the root namespace with
          ATH-240 pages.
        schema: { type: string, maxLength: 24 }
    put:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [KnowledgeBase]
      operationId: upsertArticleTranslation
      summary: Create or replace the variant of an article in one locale
      description: >
        ATH-242 — idempotent per (article, locale): one article has at
        most one variant per locale, so this is a PUT rather than a POST
        and calling it twice leaves one row. Requires `articles.manage`
        and the `feature.content.multilingual` flag. Body HTML is
        sanitized with the same allowlist as article bodies. Recording a
        translation reconciles it against the current source body, which
        clears the `stale` flag. Creating never publishes — a new
        variant is always a draft and reaches readers only through the
        status endpoint. The default locale is refused with 422: the
        article itself IS the default locale and a variant of it would
        be a second source of truth for the same URL.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ArticleTranslationInput" }
      responses:
        "200":
          description: Replaced
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/ArticleTranslation" }
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/ArticleTranslation" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }
    delete:
      tags: [KnowledgeBase]
      operationId: deleteArticleTranslation
      summary: Remove the variant of an article in one locale
      description: >
        ATH-242 — requires `articles.manage` and the
        `feature.content.multilingual` flag. Removing a published
        variant also removes it from the hreflang set and the sitemap of
        every other locale, because both are computed from the live
        variants rather than stored.
      responses:
        "204": { description: Deleted }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /workspaces/{workspaceId}/articles/{articleId}/translations/{locale}/status:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - name: articleId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
      - name: locale
        in: path
        required: true
        schema: { type: string, maxLength: 24 }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [KnowledgeBase]
      operationId: setArticleTranslationStatus
      summary: Publish or unpublish ONE locale independently
      description: >
        ATH-242 — the independent-publish seam (Doc 03 §5). A locale is
        published on its own terms: publishing German does not touch the
        source article or any other locale, and the source article being
        live does not publish German. Transitioning INTO published
        requires the same content-publish permission articles use
        ("creator submits, content_manager approves") and stamps
        approved_by plus published_at. There is no `archived` state — a
        translation that should stop serving goes back to draft and the
        work is kept.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [status]
              properties:
                status: { type: string, enum: [draft, in_review, published] }
      responses:
        "200":
          description: Transitioned
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/ArticleTranslation" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /workspaces/{workspaceId}/articles/{articleId}/translations/{locale}/ai-translate:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - name: articleId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
      - name: locale
        in: path
        required: true
        schema: { type: string, maxLength: 24 }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [KnowledgeBase]
      operationId: aiTranslateArticle
      summary: Machine-translate an article into one locale, as an unpublished draft
      description: >
        ATH-225 — the AI translation workflow (Doc 03 §5 "AI-translate
        workflow: one click → gateway translates → human review state →
        publish per locale"; Doc 05 §7 "Multilingual"). Requires
        `articles.manage` plus BOTH `feature.content.multilingual` (the
        locale surface this writes into) and `feature.ai.translation`
        (permission to spend credits writing it); below either flag the
        route is 404.


        THE RESULT IS NEVER PUBLISHED. It lands in `in_review` — the
        review state Doc 03 §5 names — so machine text cannot reach a
        visitor without a human with the content-publish permission
        transitioning it through the status endpoint. That human action
        IS the review; there is no path by which this endpoint puts
        unreviewed text on a public help centre.


        A HUMAN'S EDITS ARE NEVER SILENTLY OVERWRITTEN. A translation
        whose `origin` is `human` responds 409 unless `force` is true,
        exactly as ATH-091's sync-from-guide refuses to destroy manual
        article edits. Re-translating a stale translation is therefore
        an explicit human act, not a background job — see `stale`.


        Structure is verified rather than trusted: the model's output
        must preserve every code block and every placeholder token from
        the source. Output that does not is REJECTED (422) and the
        reason reported, never written and never silently repaired.
        Output below the confidence floor is rejected the same way.
        Every attempt — including refusals — appends a row to the
        translation's provenance history.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                force:
                  type: boolean
                  default: false
                  description: >
                    Overwrite a translation a human has edited. Default
                    false, which is what makes a re-translation safe to
                    run against a locale someone has corrected by hand.
      responses:
        "200":
          description: Translated into the locale as an unpublished draft
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/ArticleTranslationRunResult" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402":
          description: AI credits exhausted — top up to keep translating
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: >
            A human has edited this translation and force is false. The
            existing text is untouched.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /workspaces/{workspaceId}/articles/{articleId}/sync-from-guide:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - name: articleId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [KnowledgeBase]
      operationId: syncArticleFromGuide
      summary: Linked update — regenerate the article body from the current guide
      description: >
        ATH-091 — update propagation for guide-generated articles (Doc 03
        §4): re-renders the body from the guide's current steps and clears
        diverged_from_guide. If the article body was manually edited since
        generation, responds 409 unless force is true — a sync must never
        silently destroy human edits. Title/slug/collection/access/status
        are left untouched.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                force:
                  type: boolean
                  default: false
                  description: Overwrite manual body edits
      responses:
        "200":
          description: Body regenerated from the guide
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/Article" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: Body was manually edited since generation and force is false
        "422":
          description: Article is not linked to a guide, or the source guide no longer exists

  /workspaces/{workspaceId}/articles/{articleId}/feedback:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - name: articleId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [KnowledgeBase]
      operationId: submitArticleFeedback
      summary: 👍/👎 + optional comment (one vote per submitter; re-vote updates)
      description: >
        Requires `guides.view`. Scoped to the workspace in the path — a
        member of another workspace gets 403, never another tenant’s
        rows.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [rating]
              properties:
                rating: { type: string, enum: [up, down] }
                comment: { type: [string, "null"], maxLength: 2000 }
      responses:
        "201":
          description: Recorded
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    properties:
                      id: { $ref: "#/components/schemas/Ulid" }
                      rating: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }
        "429": { description: Throttled }

  /workspaces/{workspaceId}/pages:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    get:
      tags: [KnowledgeBase]
      operationId: listPages
      summary: Pages (filter by status); blocks omitted
      description: >
        ATH-240 — standalone long-form pages on the help centre (Doc 03
        §3). Requires `pages.manage` and the `feature.content.pages`
        flag; below the flag every route in this group is 404. Scoped to
        the workspace in the path — a member of another workspace gets
        403, never another tenant’s rows.
      parameters:
        - name: status
          in: query
          schema: { type: string, enum: [draft, in_review, published, archived] }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Page" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [KnowledgeBase]
      operationId: createPage
      summary: Create a page (slug derived from the title unless given)
      description: >
        ATH-240 — requires `pages.manage` and the `feature.content.pages`
        flag. Blocks are validated per type server-side and rich text is
        sanitized at the write boundary. Slug uniqueness is per WORKSPACE
        and reserved help-centre path segments are refused with 422.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PageInput" }
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/Page" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /workspaces/{workspaceId}/pages/{pageId}:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - name: pageId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    get:
      tags: [KnowledgeBase]
      operationId: getPage
      summary: Full page incl. blocks
      description: >
        ATH-240 — requires `pages.manage` and the `feature.content.pages`
        flag. Scoped to the workspace in the path — a member of another
        workspace gets 403, never another tenant’s rows.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/Page" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [KnowledgeBase]
      operationId: updatePage
      summary: Update a page (blocks revalidated and rich text resanitized)
      description: >
        ATH-240 — requires `pages.manage` and the `feature.content.pages`
        flag. Scoped to the workspace in the path — a member of another
        workspace gets 403, never another tenant’s rows.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PageInput" }
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/Page" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }
    delete:
      tags: [KnowledgeBase]
      operationId: deletePage
      summary: Delete a page
      description: >
        ATH-240 — requires `pages.manage` and the `feature.content.pages`
        flag. Scoped to the workspace in the path — a member of another
        workspace gets 403, never another tenant’s rows.
      responses:
        "204": { description: Deleted }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /workspaces/{workspaceId}/pages/{pageId}/status:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - name: pageId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [KnowledgeBase]
      operationId: setPageStatus
      summary: Workflow transition — publishing requires approval permission
      description: >
        ATH-240 — pages reuse the article workflow (Doc 03 §3 "the same
        publishing/permissions machinery"): "creator submits,
        content_manager approves", so transitioning INTO published
        additionally requires the content-publish permission and stamps
        approved_by + published_at. Only a published page renders on the
        public help centre; every other status 404s to visitors.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [status]
              properties:
                status: { type: string, enum: [draft, in_review, published, archived] }
      responses:
        "200":
          description: Transitioned
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/Page" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /workspaces/{workspaceId}/content-verifications:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    get:
      tags: [KnowledgeBase]
      operationId: listContentVerifications
      summary: Review schedules for the workspace's published knowledge
      description: >
        ATH-241 — verification workflows (Doc 03 §2.1). Requires
        `content.verify` and the `feature.content.verification` flag;
        below the flag every route in this group is 404. Scoped to the
        workspace in the path — a member of another workspace gets 403.

        This is the review QUEUE: filter by `status=due` for the work a
        content manager has to do. Ordered by `due_at` ascending so the
        most overdue is first, and cursor-paginated because a workspace
        can put its whole help centre on a cadence.

        PA-253 — filter by `unowned=true` for content whose owner left the
        workspace. Ownership is nulled when a member is removed, and this
        is where that content stays visible so a null owner is surfaced
        rather than silent: it is orthogonal to `status`, so an in-date
        schedule with no owner still shows up here.
      parameters:
        - name: status
          in: query
          schema: { type: string, enum: [verified, due, expired] }
        - name: content_type
          in: query
          schema: { type: string, enum: [article, page, guide] }
        - name: owner_id
          in: query
          schema: { $ref: "#/components/schemas/Ulid" }
        - name: unowned
          in: query
          description: >
            PA-253 — when true, return only schedules with no owner
            (owner_id null), the state a departed owner leaves behind.
          schema: { type: boolean }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
        - name: cursor
          in: query
          schema: { type: string }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/ContentVerification" }
                  meta:
                    type: object
                    properties:
                      next_cursor: { type: [string, "null"] }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /workspaces/{workspaceId}/content-verifications/{contentType}/{contentId}:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - name: contentType
        in: path
        required: true
        schema: { type: string, enum: [article, page, guide] }
      - name: contentId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    get:
      tags: [KnowledgeBase]
      operationId: getContentVerification
      summary: One review schedule plus its full attestation trail
      description: >
        ATH-241 — requires `content.verify` and the
        `feature.content.verification` flag. 404 when the content has no
        review schedule; the content itself must live in the workspace in
        the path.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/ContentVerification" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    put:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [KnowledgeBase]
      operationId: setContentVerification
      summary: Put content on a review cadence (idempotent create-or-update)
      description: >
        ATH-241 — requires `content.verify` and the
        `feature.content.verification` flag. Creates the schedule when
        the content has none and updates the owner or cadence when it
        does. Creating a schedule counts as a first verification: it is
        stamped verified by the caller now and `due_at` becomes now plus
        `interval_days`. CHANGING the cadence later re-bases `due_at` on
        the last verification rather than on the present, so shortening
        an interval can make a review immediately due — which is the
        point of shortening it.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ContentVerificationInput" }
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/ContentVerification" }
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/ContentVerification" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }
    delete:
      tags: [KnowledgeBase]
      operationId: deleteContentVerification
      summary: Take content off its review cadence
      description: >
        ATH-241 — requires `content.verify` and the
        `feature.content.verification` flag. Removes the schedule so the
        sweep stops raising reviews. The attestation records go with it:
        the trail exists to support a live claim about live content, and
        keeping orphaned rows after the schedule is deliberately dropped
        would outlive the claim they document.
      responses:
        "204": { description: Removed }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /workspaces/{workspaceId}/content-verifications/{contentType}/{contentId}/verify:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - name: contentType
        in: path
        required: true
        schema: { type: string, enum: [article, page, guide] }
      - name: contentId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [KnowledgeBase]
      operationId: verifyContent
      summary: Record that a human has re-read this content
      description: >
        ATH-241 — the re-verify action (Doc 03 §2.1). Requires
        `content.verify` and the `feature.content.verification` flag.
        Stamps the caller as `verified_by`, resets `status` to `verified`
        and pushes `due_at` out by `interval_days` from now. Appends an
        immutable record naming who attested and when; that trail is the
        feature, so the record is written in the same transaction as the
        status change and neither can land without the other.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                note: { type: [string, "null"], maxLength: 2000 }
      responses:
        "200":
          description: Verified
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/ContentVerification" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /workspaces/{workspaceId}/content-versions/{contentType}/{contentId}:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - name: contentType
        in: path
        required: true
        schema: { type: string, enum: [article, page] }
      - name: contentId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    get:
      tags: [KnowledgeBase]
      operationId: listContentVersions
      summary: Version history for an article or page, newest first
      description: >
        ATH-024 — snapshot history (Doc 03 §2.1). Requires
        `guides.publish` (the content-publish permission, which
        ArticleController and PageController already use to gate
        publishing) and the `feature.content.versioning` flag.


        `contentType` is `article` or `page` and NOT `guide`: guides keep
        their own history at `/guides/{guideId}/versions`, on a separate
        table, because a guide's body is `guide_steps` rows rather than a
        column and Doc 03 §2.1 mandates a never-delete plan window for
        them that is incompatible with the storage cap here.


        Retention is a hard cap of the newest N per content item (default
        20). Beyond the cap the OLDEST snapshots are deleted; numbers are
        never reused, so a pruned version 404s rather than resolving to a
        different snapshot.
      responses:
        "200":
          description: Versions, newest first
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/ContentVersionSummary" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /workspaces/{workspaceId}/content-versions/{contentType}/{contentId}/{number}:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - name: contentType
        in: path
        required: true
        schema: { type: string, enum: [article, page] }
      - name: contentId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
      - name: number
        in: path
        required: true
        schema: { type: integer, minimum: 1 }
    get:
      tags: [KnowledgeBase]
      operationId: getContentVersion
      summary: One snapshot, with its full payload
      description: >
        ATH-024 — the stored snapshot as it was captured. Requires
        `guides.publish` and the `feature.content.versioning` flag.


        The payload is what was captured, which is deliberately WIDER
        than what a restore puts back: it records `status` and
        `access_level` for the audit trail, and restore never reapplies
        either (see the restore operation).
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/ContentVersion" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /workspaces/{workspaceId}/content-versions/{contentType}/{contentId}/{number}/restore:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - name: contentType
        in: path
        required: true
        schema: { type: string, enum: [article, page] }
      - name: contentId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
      - name: number
        in: path
        required: true
        schema: { type: integer, minimum: 1 }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [KnowledgeBase]
      operationId: restoreContentVersion
      summary: Roll content back to a snapshot, or refuse
      description: >
        ATH-024 — restore (Doc 03 §2.1, §8 scenario 8). Requires
        `guides.publish` and the `feature.content.versioning` flag.
        Publish-level and not merely edit-level on purpose: one call
        replaces the body of content that may be live to the public,
        with no review step, so the role trusted to approve what goes
        live is the role trusted to roll it back.


        WHAT IS RESTORED is the CONTENT only — title, body or blocks,
        and (for a page) the slug. `status`, `published_at`,
        `approved_by` and `access_level` are left exactly as they are, so
        a rollback can undo an authoring mistake but can never silently
        republish archived content or widen who may read it.


        WHAT REFUSES: the snapshot is re-validated through the same code
        an ordinary write runs — block shape and referents for a page,
        HTML sanitization and collection existence for an article — and a
        snapshot that would no longer be accepted as a fresh write is
        rejected 422 with NOTHING written. A block type since retired, an
        embedded guide since deleted, an image since removed, a slug
        since taken by another page: each refuses loudly rather than
        persisting a body the renderer cannot draw onto a live public
        page.


        Restoring is itself undoable: the state being overwritten is
        snapshotted first, in the same transaction.
      responses:
        "200":
          description: Restored
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/ContentVersion" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /workspaces/{workspaceId}/kb/search:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    get:
      tags: [KnowledgeBase]
      operationId: searchKb
      summary: KB search v1 — published articles by title/body
      description: >
        ATH-099 — Postgres FTS (tsvector + ts_rank) in production, LIKE
        fallback on other drivers; Meilisearch swaps in at P4 behind the
        same shape. Members see every published article regardless of
        access level (they are workspace members); the public help-center
        search applies visitor access rules instead.
      parameters:
        - name: q
          in: query
          required: true
          schema: { type: string, minLength: 2, maxLength: 200 }
      responses:
        "200":
          description: Ranked matches
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      required: [id, title, slug]
                      properties:
                        id: { $ref: "#/components/schemas/Ulid" }
                        title: { type: string }
                        slug: { type: string }
                        collection_id: { type: [string, "null"] }
                        access_level: { type: string, enum: [public, unlisted, authenticated, internal] }
                        excerpt: { type: [string, "null"] }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /realtime/resume:
    get:
      tags: [Realtime]
      operationId: resumeChannel
      summary: Missed-event fetch by cursor (member principal)
      description: >
        ATH-111 — on reconnect, send the channel and your highest seen
        cursor; receive every DURABLE event after it in cursor order.
        Ephemeral events (typing, presence, page telemetry) are never
        replayed. Authorization is the same ChannelAuthorizer that
        grants live subscriptions. resync=true means continuity can't
        be proven (cursor predates retention) — refetch state via REST
        instead of trusting the replay.
      parameters:
        - name: channel
          in: query
          required: true
          schema: { type: string, maxLength: 200 }
          description: Wire channel name (private-…/presence-…) or bare stem
        - name: after
          in: query
          schema: { type: integer, minimum: 0, default: 0 }
      responses:
        "200":
          description: Ordered replay
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RealtimeResumeBatch" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ValidationError" }
        "429": { description: Throttled }

  /widget/realtime/resume:
    get:
      tags: [Realtime]
      operationId: resumeVisitorChannel
      summary: Missed-event fetch by cursor (visitor principal)
      description: >
        ATH-111 — identical contract to /realtime/resume, authenticated
        by the bearer visitor token; grants limited to the visitor's
        own channels.
      parameters:
        - name: channel
          in: query
          required: true
          schema: { type: string, maxLength: 200 }
        - name: after
          in: query
          schema: { type: integer, minimum: 0, default: 0 }
      responses:
        "200":
          description: Ordered replay
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RealtimeResumeBatch" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ValidationError" }
        "429": { description: Throttled }

  /widget/session:
    post:
      tags: [Widget]
      operationId: createWidgetSession
      summary: Open an anonymous visitor session (public website key)
      description: >
        ATH-117 — the embed snippet's ws_pub key is the only credential;
        it can mint anonymous visitor sessions and nothing else. The
        bearer token is returned exactly once and lives in the widget's
        localStorage. Suspended workspaces don't serve.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [website_id]
              properties:
                website_id: { type: string, maxLength: 40 }
      responses:
        "201":
          description: Session opened
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    required: [visitor_id, token]
                    properties:
                      visitor_id: { $ref: "#/components/schemas/Ulid" }
                      token: { type: string, description: Show-once bearer token }
                      workspace:
                        type: object
                        properties:
                          name: { type: string }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }
        "429": { description: Throttled }

  /widget/contact:
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Widget]
      operationId: upgradeWidgetContact
      summary: Email capture — upgrade the visitor to a contact
      description: >
        ATH-117 — find-or-create by (workspace, email), link the
        visitor, and stamp the contact onto the visitor's existing
        conversations so history follows the person.
        ATH-118 — pass hmac = HMAC-SHA256(lowercased email,
        widget_identity_secret), signed by YOUR backend, to verify the
        identity (Crisp-style anti-impersonation). An invalid or
        unconfigured signature is 403; claiming an already-verified
        identity WITHOUT a signature is 403; verification only ever
        ratchets up.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email: { type: string, format: email, maxLength: 255 }
                name: { type: [string, "null"], maxLength: 255 }
                # Quoted deliberately: an unquoted value containing a
                # comma inside a { } flow mapping splits into a second,
                # null-valued key — which silently truncates this
                # description AND crashes Spectral (ATH-004).
                hmac:
                  type: [string, "null"]
                  maxLength: 64
                  description: "HMAC-SHA256(email, widget_identity_secret) hex"
      responses:
        "200":
          description: Visitor linked to the contact
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    required: [contact_id, email, verified]
                    properties:
                      contact_id: { $ref: "#/components/schemas/Ulid" }
                      email: { type: string }
                      verified: { type: boolean }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /workspaces/{workspaceId}/conversations:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    get:
      tags: [Inbox]
      operationId: listConversations
      summary: Agents' conversation list (filters, previews)
      description: >
        Requires `inbox.view`. Scoped to the workspace in the path — a
        member of another workspace gets 403, never another tenant’s
        rows.
      parameters:
        - { name: state, in: query, schema: { type: string, enum: [pending, open, snoozed, resolved] } }
        - { name: assignee_id, in: query, schema: { type: string } }
        - { name: inbox_id, in: query, schema: { type: string } }
        - name: kind
          in: query
          description: "ATH-214 — narrow the same list to tickets (or to non-ticket conversations)"
          schema: { type: string, enum: [conversation, ticket] }
        - name: quarantined
          in: query
          description: >
            ATH-221 — the spam folder. Omitted or false, quarantined
            conversations are EXCLUDED, which is the whole benefit of
            quarantine: this list is capped at 100 rows, so leaving spam
            in it would push real customers off the end. Pass true to
            list only the quarantined ones and release false positives
            from there.
          schema: { type: boolean }
      responses:
        "200":
          description: Newest-first, capped 100
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/ConversationSummary" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /workspaces/{workspaceId}/conversations/{conversationId}/messages:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - name: conversationId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    get:
      tags: [Inbox]
      operationId: listConversationMessages
      summary: Full thread INCLUDING private notes (agents only)
      description: >
        Requires `inbox.view`. Scoped to the workspace in the path — a
        member of another workspace gets 403, never another tenant’s
        rows.
      responses:
        "200":
          description: Oldest-first, capped 500
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/InboxMessage" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Inbox]
      operationId: sendConversationMessage
      summary: Reply (or private note) via the shared message path
      description: >
        ATH-131 — note=true writes a private note: it NEVER publishes
        on the conversation channel (the visitor holds it) — notes ride
        the agent-only workspace inbox channel.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [body]
              properties:
                body: { type: string, maxLength: 10000 }
                note: { type: boolean, default: false }
      responses:
        "201":
          description: Sent
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    properties:
                      id: { $ref: "#/components/schemas/Ulid" }
                      direction: { type: string, enum: [out, note] }
                      created_at: { type: [string, "null"], format: date-time }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /workspaces/{workspaceId}/conversations/{conversationId}/context:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - name: conversationId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    get:
      tags: [Inbox]
      operationId: getConversationContext
      summary: Context sidebar — profile, live session, history
      description: >
        Requires `inbox.view`. Scoped to the workspace in the path — a
        member of another workspace gets 403, never another tenant’s
        rows.
      responses:
        "200":
          description: Sidebar payload
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    properties:
                      contact: { type: [object, "null"] }
                      visitor: { type: [object, "null"] }
                      history:
                        type: array
                        items:
                          type: object
                          properties:
                            id: { $ref: "#/components/schemas/Ulid" }
                            state: { type: string }
                            channel: { type: string }
                            rating: { type: [integer, "null"] }
                            last_message_at: { type: [string, "null"], format: date-time }
                      # ATH-221 — the three AI utilities render in this
                      # sidebar, so they ride the payload it already
                      # fetches rather than three more round trips.
                      # Null summary/spam simply means "not generated"
                      # and "never classified"; the sidebar renders the
                      # buttons either way.
                      summary:
                        oneOf:
                          - { $ref: "#/components/schemas/AiConversationSummary" }
                          - { type: "null" }
                      tags:
                        type: array
                        items: { $ref: "#/components/schemas/ConversationTag" }
                      spam:
                        oneOf:
                          - { $ref: "#/components/schemas/SpamVerdict" }
                          - { type: "null" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /workspaces/{workspaceId}/conversations/{conversationId}/copilot:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - name: conversationId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Inbox]
      operationId: runCopilot
      summary: Agent-assist copilot — ask, draft a reply, or rewrite tone
      description: >
        ATH-220 (Doc 05 §7). The agent-FACING assistant: it answers a
        teammate's question about the conversation or the knowledge
        base, drafts a reply for that teammate to edit and send, or
        rewrites their draft in a different tone. It never sends
        anything — the human sends.


        Requires `inbox.reply` and the `feature.ai.full_suite` plan
        entitlement; below the flag the surface 404s. Unlike the
        customer-facing agent this path retrieves `internal` audience
        knowledge as well as `public` and `customers`, so its answers
        may quote material a customer must never see. Every call is
        metered through the LLM gateway and debits AI credits; an
        exhausted workspace gets 402 rather than a silent failure.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CopilotRequest" }
      responses:
        "200":
          description: Suggestion produced
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/CopilotSuggestion" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402":
          description: AI credits exhausted — top up to keep using the copilot
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /workspaces/{workspaceId}/conversations/{conversationId}/summary:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - name: conversationId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Inbox]
      operationId: summariseConversation
      summary: Generate (or regenerate) the AI summary of a conversation
      description: >
        ATH-221 (Doc 05 §7). Summarises the thread for a teammate
        picking it up cold. Idempotent per conversation — there is one
        summary row, and a repeat call REGENERATES it in place against
        the transcript as it now stands. A summary generated before
        later messages arrived is marked `stale` so nobody acts on a
        stale picture without knowing it is one.


        Requires `inbox.reply` and the `feature.ai.full_suite` plan
        entitlement plus the `feature.ai.summaries` rollout flag; below
        either the surface 404s. Metered through the LLM gateway and
        debits AI credits; an exhausted workspace gets 402.
      responses:
        "200":
          description: Summary generated
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/AiConversationSummary" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402":
          description: AI credits exhausted — top up to keep summarising
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /workspaces/{workspaceId}/conversations/{conversationId}/translation:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - name: conversationId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Inbox]
      operationId: setConversationTranslation
      summary: Turn LiveTranslate on or off for one conversation
      description: >
        ATH-223 (Doc 04 §9). The per-conversation auto-translate toggle.
        Switching it ON translates subsequent messages in BOTH
        directions — inbound into `agent_locale` for the team, outbound
        into the customer's detected language for them — and backfills a
        bounded window of the existing thread so the agent can read what
        was said before they arrived.


        Translation never blocks delivery. Messages are written and
        broadcast first and translated afterwards on a queued job, so a
        gateway outage or an exhausted balance degrades the conversation
        to untranslated rather than breaking it. Nothing on this surface
        returns 402 for that reason: the trigger for a translation is a
        customer sending a message, and a customer is not the person who
        can top up a workspace's credits.


        Switching it OFF stops future translations and deletes nothing —
        existing translations stay readable, because a thread an agent
        has been reading in English should not become unreadable
        mid-conversation.


        Requires `inbox.reply` and the `feature.ai.full_suite` plan
        entitlement plus the `feature.ai.live_translate` rollout flag;
        below either the surface 404s.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [enabled]
              properties:
                enabled: { type: boolean }
                agent_locale:
                  type: string
                  maxLength: 24
                  description: >
                    The language the team reads this thread in. Required
                    when enabling; a BCP-47 tag whose language subtag is
                    on ATH-242's closed ISO 639-1 allowlist.
      responses:
        "200":
          description: Toggle applied
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/LiveTranslateSettings" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /workspaces/{workspaceId}/conversations/{conversationId}/cobrowse:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - name: conversationId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Inbox]
      operationId: requestCobrowseSession
      summary: Ask the visitor to share their screen (co-browse)
      description: >
        ATH-261 (Doc 04 §9). An agent requests a live co-browse session on
        a widget conversation. This does NOT start streaming: it creates a
        `pending` session and prompts the visitor, who must EXPLICITLY
        accept before a single frame is relayed (consent is mandatory, and
        the server refuses frames until it is given). The session
        auto-expires after a bounded window, and either party can end it.


        The visitor's screen is serialised with rrweb and relayed as
        EPHEMERAL realtime events on the conversation channel — never
        stored. Password fields and anything the site marks `[data-private]`
        are masked in the visitor's browser before serialisation, so the
        raw values never leave the page (CLAUDE.md rule 3 in a live
        medium). This session row is the consent + lifecycle record only.


        Only widget conversations carry a visitor to watch; other channels
        422. Requires `inbox.reply` and the `feature.messaging.cobrowse`
        flag (404 below it).
      responses:
        "201":
          description: Session requested; the visitor has been prompted for consent.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/CobrowseSession" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /workspaces/{workspaceId}/conversations/{conversationId}/cobrowse/{sessionId}/end:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - name: conversationId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
      - name: sessionId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Inbox]
      operationId: endCobrowseSession
      summary: End a co-browse session (agent side)
      description: >
        ATH-261 — the agent stops watching. Idempotent: ending an
        already-ended session returns its terminal state rather than
        erroring. Publishes `cobrowse.ended` so the visitor's browser
        stops capturing at once. Requires `inbox.reply`.
      responses:
        "200":
          description: Ended (or already terminal).
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/CobrowseSession" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /workspaces/{workspaceId}/conversations/{conversationId}/cobrowse/{sessionId}/guide-capture:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - name: conversationId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
      - name: sessionId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Inbox]
      operationId: requestGuideCapture
      summary: Ask the visitor to turn this co-browse session into a guide
      description: >
        PA-261 (Doc 07 §3 / cobrowse→guide, Option B 2 of 3). On an ACTIVE,
        consented co-browse session, the agent proposes recording it into an
        editable guide. This does NOT start recording: it creates a pending
        guide-capture request and prompts the visitor, who must give a
        DISTINCT, explicit consent — separate from the co-browse view consent
        — on the widget before a single step is ingested. Any prior live
        guide-capture request on this session is superseded (revoked).
        Requires `inbox.reply` and the `feature.cobrowse.guide_capture` flag
        (404 below it). 422 if the co-browse session is not active.
      responses:
        "201":
          description: Guide capture requested; the visitor has been prompted for consent.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/CobrowseGuideCapture" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /workspaces/{workspaceId}/conversations/{conversationId}/calls:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - name: conversationId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Inbox]
      operationId: requestCallSession
      summary: Ask the visitor to join an audio or video call
      description: >
        ATH-262 (Doc 04 §9) — an agent asks the visitor onto a LiveKit
        call. This does NOT open anyone's microphone: it creates a
        `pending` session and prompts the visitor, who must EXPLICITLY
        accept. No media token is minted for EITHER participant until
        they do, so an agent cannot reach a visitor's mic or camera
        without their consent. The visitor may narrow what they accept
        (a video request accepted as audio) but never widen it.


        Media itself flows peer-to-SFU through LiveKit and never touches
        Cairn's realtime fabric; only the invite/accept/end signalling
        rides the existing conversation and visitor channels. Nothing is
        recorded — see `CallToken` for the grant that says so.


        Only widget conversations carry a visitor to call; other channels
        422. Requires `inbox.reply` and the `feature.messaging.av_calls`
        flag (404 below it).
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                media:
                  type: string
                  enum: [audio, video]
                  default: audio
                  description: >
                    What the agent is asking for. `audio` grants only the
                    microphone; `video` grants camera as well. Defaults to
                    audio — the smaller ask.
      responses:
        "201":
          description: Call requested; the visitor has been prompted for consent.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/CallSession" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /workspaces/{workspaceId}/conversations/{conversationId}/calls/{callId}/token:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - name: conversationId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
      - name: callId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Inbox]
      operationId: mintAgentCallToken
      summary: Mint the agent's short-lived LiveKit join token
      description: >
        ATH-262 — the agent's join credential. Refused with 409 unless
        the session is joinable: `active`, carrying a non-null
        `consented_at`, unexpired and unended. A pre-consent token
        request gets nothing, which is the guarantee — the agent cannot
        enter the room before the visitor has said yes.


        The token is a short-lived JWT signed with the LiveKit API secret
        (which never leaves the server), scoped to exactly one room and
        one identity, and it never outlives the call session's own
        window. Re-request it on reconnect: every issue re-checks consent
        and expiry, so our server stays the authority rather than a
        long-lived bearer. Requires `inbox.reply`.
      responses:
        "200":
          description: A join token scoped to this room and identity.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/CallToken" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "503": { description: No LiveKit deployment is configured for this environment. }

  /workspaces/{workspaceId}/conversations/{conversationId}/calls/{callId}/end:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - name: conversationId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
      - name: callId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Inbox]
      operationId: endCallSession
      summary: Hang up (agent side)
      description: >
        ATH-262 — the agent hangs up. Idempotent: ending an already-ended
        call returns its terminal state rather than erroring. Publishes
        `call.ended` so the visitor's browser tears its LiveKit room down,
        and asks LiveKit to delete the room so no participant lingers in
        it. Requires `inbox.reply`; deliberately NOT flag-gated, so a live
        call can always be stopped.
      responses:
        "200":
          description: Ended (or already terminal).
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/CallSession" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /workspaces/{workspaceId}/translation-glossary:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
    put:
      tags: [Inbox]
      operationId: setTranslationGlossary
      summary: Set the workspace's LiveTranslate glossary
      description: >
        ATH-223 (Doc 04 §9, "glossary support"). A flat term → rendering
        map applied to every LiveTranslate call for this workspace.
        Workspace-wide rather than per conversation because a glossary
        answers "what do we call our own product in German", and that
        answer does not change between two threads — per-conversation
        glossaries would produce the exact inconsistency a glossary
        exists to prevent.


        A full replacement, not a merge: sending `{}` clears it. Terms
        are interpolated into the model prompt, so they are length-capped
        and count-capped.


        Requires `ai.configure` and the `feature.ai.full_suite` plan
        entitlement plus the `feature.ai.live_translate` rollout flag.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [glossary]
              properties:
                glossary:
                  type: object
                  maxProperties: 200
                  additionalProperties:
                    type: string
                    maxLength: 200
                  description: term → preferred rendering in the target language.
      responses:
        "200":
          description: Glossary replaced
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    required: [glossary]
                    properties:
                      glossary:
                        type: object
                        additionalProperties:
                          type: string
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /workspaces/{workspaceId}/conversations/{conversationId}/auto-tag:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - name: conversationId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Inbox]
      operationId: autoTagConversation
      summary: Classify the conversation against the workspace tag vocabulary
      description: >
        ATH-221 (Doc 05 §7 "classifier call on resolve"). This endpoint
        APPLIES tags, it does not merely propose them — Doc 05 §7 says
        auto-tagging "powers analytics + mining", and a suggestion
        queue would mean analytics saw only the subset a human got
        round to approving.


        Because it applies them, every tag carries PROVENANCE: `source`
        records whether a model, a human, or a workflow put it there,
        and the assignment log is append-only, so "the AI tagged this
        conversation `refund` and a human removed it" is reconstructible
        after the fact. The model may only choose from tags the
        workspace already uses — it never invents vocabulary. Removing
        a wrong tag is `POST .../tags` with `remove`.


        Requires `inbox.reply`, `feature.ai.full_suite` and the
        `feature.ai.auto_tag` rollout flag. Metered; 402 when exhausted.
      responses:
        "200":
          description: Classification complete
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/AutoTagResult" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402":
          description: AI credits exhausted — top up to keep auto-tagging
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /workspaces/{workspaceId}/conversations/{conversationId}/tags:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - name: conversationId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Inbox]
      operationId: setConversationTags
      summary: Add or remove conversation tags as a human
      description: >
        ATH-221 — the human half of the tag surface, and the correction
        path for anything auto-tagging got wrong. Writes carry
        `source: human` and the acting user, so the assignment log
        distinguishes what a model did from what a person did.


        No AI call, no credits. Requires `inbox.reply`; needs neither AI
        flag, because tagging by hand is not an AI feature and must keep
        working when the utilities are switched off.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: At least one of `add` or `remove` must be present and non-empty.
              properties:
                add:
                  type: array
                  maxItems: 20
                  items: { type: string, maxLength: 50 }
                remove:
                  type: array
                  maxItems: 20
                  items: { type: string, maxLength: 50 }
      responses:
        "200":
          description: Tags after the change
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    required: [tags]
                    properties:
                      tags:
                        type: array
                        items: { $ref: "#/components/schemas/ConversationTag" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /workspaces/{workspaceId}/conversations/{conversationId}/spam:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - name: conversationId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Inbox]
      operationId: setConversationSpam
      summary: Quarantine a conversation as spam, or release it back to the inbox
      description: >
        ATH-221 — the reversal path for the spam filter, and the manual
        quarantine control (Doc 05 §7).


        The asymmetry that shapes this surface: a false positive hides a
        real customer, so spam is QUARANTINED and never deleted. The
        conversation, its messages and its contact all survive
        untouched; only `quarantined_at` changes, and the thread stays
        readable through every existing endpoint. `spam: false` releases
        it — the row is restored to the inbox, the correction is
        recorded against the original verdict, and the conversation is
        never re-classified afterwards, so releasing something is final
        rather than a thing the model can undo on the next message.


        No AI call, no credits — this is a human overriding a
        classifier. Requires `inbox.reply` and no AI flag: a workspace
        that switches the spam filter off must still be able to release
        whatever it quarantined while it was on.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [spam]
              properties:
                spam:
                  type: boolean
                  description: true quarantines the conversation; false releases it back to the inbox.
                reason:
                  type: [string, "null"]
                  maxLength: 500
                  description: Optional note recorded on the verdict — why a human disagreed.
      responses:
        "200":
          description: Verdict after the change
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/SpamVerdict" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /workspaces/{workspaceId}/conversations/{conversationId}/dictation:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - name: conversationId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Inbox]
      operationId: transcribeDictation
      summary: Transcribe a teammate's spoken reply into composer text
      description: >
        ATH-222 — voice-to-text (Doc 05 §7). A teammate holds the mic
        button in the inbox composer, speaks a reply, and gets text back
        to edit and send. The transcript is RETURNED, never sent: it
        lands in the composer through ATH-220's insertion seam and a
        human presses send, exactly as with a copilot draft.


        AUDIO IS NOT RETAINED. The recording is staged privately,
        transcribed, and deleted before this endpoint responds — on the
        failure path too. No media row is written and there is nothing
        to fetch afterwards, by design: a dictated reply routinely
        speaks a customer's details aloud, and a voice is personal data
        in its own right. The transcript is the artefact; the audio is a
        courier (CLAUDE.md rule 3, transposed from typed input to
        spoken).


        Metered by DURATION, not tokens (Doc 05 §9, "Voice-to-text (per
        min) 1¢"), charged in whole minutes rounded up, against the
        longer of the client's declared duration and the duration the
        provider actually transcribed. Requires `inbox.reply`, the
        `feature.ai.full_suite` plan entitlement and the
        `feature.ai.voice_to_text` rollout flag; below either the
        surface 404s. An exhausted workspace gets 402.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file, duration_ms]
              properties:
                file:
                  type: string
                  format: binary
                  description: The recording — webm/ogg/mp3/m4a/mp4/wav/flac, max 10 MB. Anything else is 422.
                duration_ms:
                  type: integer
                  minimum: 1
                  maximum: 120000
                  description: >
                    Recording length as the browser measured it, used to
                    gate credits before the call. Capped at two minutes;
                    longer is 422. It is a floor on the price, not the
                    price — the debit re-derives duration from the
                    transcript.
                language:
                  type: [string, "null"]
                  maxLength: 8
                  description: BCP-47 hint for the recogniser. Omitted, the provider detects it.
      responses:
        "200":
          description: Transcript produced — insert it, do not send it
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/VoiceTranscript" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402":
          description: AI credits exhausted — top up to keep dictating
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422":
          description: >
            Unusable input — oversize, over-long, an unsupported format,
            or audio the recogniser heard no speech in
            (`transcription_empty`). Never an empty transcript with a
            200: a blank insert is how a teammate sends a blank reply.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "502":
          description: >
            The transcription provider failed. Ours, not the speaker's —
            the recording is already deleted, so the client must ask for
            a fresh recording rather than offer a retry.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /workspaces/{workspaceId}/conversations/{conversationId}/state:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - name: conversationId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Inbox]
      operationId: setConversationState
      summary: Transition state (publishes conversation.state_changed)
      description: >
        Requires `inbox.reply`. Scoped to the workspace in the path — a
        member of another workspace gets 403, never another tenant’s
        rows.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [state]
              properties:
                state: { type: string, enum: [pending, open, snoozed, resolved] }
      responses:
        "200":
          description: Transitioned
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id: { $ref: "#/components/schemas/Ulid" }
                      state: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /workspaces/{workspaceId}/conversations/{conversationId}/typing:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - name: conversationId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Inbox]
      operationId: sendConversationTyping
      summary: Agent typing signal to the visitor (ephemeral)
      description: >
        Requires `inbox.reply`. Scoped to the workspace in the path — a
        member of another workspace gets 403, never another tenant’s
        rows.
      responses:
        "204": { description: Published }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { description: Throttled }

  /workspaces/{workspaceId}/conversations/{conversationId}/assign:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - name: conversationId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Inbox]
      operationId: assignConversation
      summary: Assign — explicit agent, unassign (null), or round-robin
      description: >
        ATH-132 — round_robin=true picks the eligible agent (a role
        holding inbox.reply) with the fewest active conversations,
        tie-broken deterministically. Publishes conversation.assigned.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                assignee_id: { type: [string, "null"] }
                round_robin: { type: boolean, default: false }
      responses:
        "200":
          description: Assigned
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id: { $ref: "#/components/schemas/Ulid" }
                      assignee_id: { type: [string, "null"] }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /workspaces/{workspaceId}/contacts:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    get:
      tags: [Contacts]
      operationId: listContacts
      summary: Search contacts (contacts.view)
      description: >
        ATH-137 — name/email substring search, most-recently-seen first,
        capped at 50.
      parameters:
        - name: q
          in: query
          required: false
          schema: { type: string, maxLength: 100 }
      responses:
        "200":
          description: Matching contacts
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/ContactProfile" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /workspaces/{workspaceId}/contacts/{contactId}:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - name: contactId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    get:
      tags: [Contacts]
      operationId: getContact
      summary: Contact profile with conversation history (contacts.view)
      description: >
        Requires `contacts.view`. Scoped to the workspace in the path —
        a member of another workspace gets 403, never another tenant’s
        rows.
      responses:
        "200":
          description: Profile + last 20 conversations
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    allOf:
                      - $ref: "#/components/schemas/ContactProfile"
                      - type: object
                        # ATH-007 — the controller always sends these; say
                        # so, or every consumer defends against undefined
                        # that never arrives.
                        required: [conversations]
                        properties:
                          conversations:
                            type: array
                            items:
                              type: object
                              required: [id, channel, state, rating, last_message_at]
                              properties:
                                id: { $ref: "#/components/schemas/Ulid" }
                                channel: { type: string }
                                state: { type: string }
                                rating: { type: [integer, "null"] }
                                last_message_at: { type: [string, "null"], format: date-time }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Contacts]
      operationId: updateContact
      summary: Update profile fields and custom attributes (contacts.manage)
      description: >
        Requires `contacts.manage`. Scoped to the workspace in the path
        — a member of another workspace gets 403, never another tenant’s
        rows.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: [string, "null"], maxLength: 255 }
                phone: { type: [string, "null"], maxLength: 32 }
                attributes:
                  type: object
                  maxProperties: 30
                  additionalProperties:
                    type: [string, number, boolean, "null"]
                  description: Freeform scalar custom attributes.
      responses:
        "200":
          description: Updated profile
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/ContactProfile" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /workspaces/{workspaceId}/contacts/import:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Contacts]
      operationId: importContacts
      summary: CSV import with email dedupe (contacts.manage)
      description: >
        ATH-137 — header row required, email column mandatory;
        name/phone map to profile fields, other columns become custom
        attributes. Existing contacts (matched by email) update —
        blanks never clobber, attributes merge. Invalid emails are
        skipped and counted. Throttled 10/min.

        ATH-047 — if the workspace hits its plan's contact ceiling
        mid-file, the rows that fit still import and the rest come back
        as `limit_skipped` rather than failing the whole upload.
        Existing contacts keep updating even when over the limit.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [csv]
              properties:
                csv: { type: string, maxLength: 1000000 }
      responses:
        "200":
          description: Import report
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    required: [created, updated, skipped, limit_skipped]
                    properties:
                      created: { type: integer }
                      updated: { type: integer }
                      skipped: { type: integer }
                      limit_skipped: { type: integer }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /workspaces/{workspaceId}/segments:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    get:
      tags: [Segments]
      operationId: listSegments
      summary: List saved segments with materialized counts (contacts.view)
      description: >
        ATH-210 — alphabetical. `contact_count` and `refreshed_at` come
        from the materialized snapshot (nightly recount + incremental
        per-contact refresh), not a live query.
      responses:
        "200":
          description: Segments, alphabetical by name
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Segment" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Segments]
      operationId: createSegment
      summary: Create a segment (contacts.manage)
      description: >
        ATH-210 — `definition` is validated as a filter AST (kind-by-kind;
        invalid trees 422 with node paths). Membership materializes via a
        queued job, so `contact_count` reads 0 until it lands — use the
        preview endpoint for an immediate count. Names are unique per
        workspace. Throttled 30/min.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, definition]
              properties:
                name: { type: string, maxLength: 120 }
                definition: { $ref: "#/components/schemas/SegmentFilterNode" }
      responses:
        "201":
          description: Created; materialization queued
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/Segment" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /workspaces/{workspaceId}/segments/preview:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Segments]
      operationId: previewSegment
      summary: Live-evaluate a filter tree — count + sample (contacts.view)
      description: >
        ATH-210 — evaluates `definition` against the workspace's contacts
        NOW (no persistence): total match count plus a most-recently-seen
        sample of up to 10. Invalid trees 422 with node paths. Throttled
        30/min — it runs real queries.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [definition]
              properties:
                definition: { $ref: "#/components/schemas/SegmentFilterNode" }
      responses:
        "200":
          description: Match count + sample
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/SegmentPreview" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /workspaces/{workspaceId}/segments/{segmentId}:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - name: segmentId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    get:
      tags: [Segments]
      operationId: getSegment
      summary: Segment with its filter definition (contacts.view)
      description: >
        Requires `contacts.view`. Scoped to the workspace in the path —
        a member of another workspace gets 403, never another tenant's
        rows.
      responses:
        "200":
          description: The segment
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/Segment" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Segments]
      operationId: updateSegment
      summary: Rename or redefine a segment (contacts.manage)
      description: >
        ATH-210 — a changed `definition` re-validates the AST and queues a
        full re-materialization; `contact_count` reflects the OLD filter
        until the job lands.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string, maxLength: 120 }
                definition: { $ref: "#/components/schemas/SegmentFilterNode" }
      responses:
        "200":
          description: Updated segment
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/Segment" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }
    delete:
      tags: [Segments]
      operationId: deleteSegment
      summary: Delete a segment (contacts.manage)
      description: >
        Requires `contacts.manage`. Membership rows cascade and member
        contacts' cached segment lists are rewritten. Scoped to the
        workspace in the path.
      responses:
        "204": { description: Deleted }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /workspaces/{workspaceId}/campaigns:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    get:
      tags: [Campaigns]
      operationId: listCampaigns
      summary: List campaigns, newest first (campaigns.manage)
      description: >
        ATH-211 — cursor-paginated, newest first. `stats` are the
        campaign's own counters, incremented as the fan-out runs, not a
        live aggregate over the recipient ledger.
      parameters:
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
        - name: status
          in: query
          schema: { type: string, enum: [draft, scheduled, sending, sent, paused] }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/PaginatedList"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/Campaign" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Campaigns]
      operationId: createCampaign
      summary: Create a draft campaign (campaigns.manage)
      description: >
        ATH-211 — created in `draft`; nothing sends until the send
        endpoint is called. Requires the `feature.messaging.campaigns`
        plan entitlement (Growth and above) — without it every write
        422s. `segment_id` must name a segment in the same workspace.
        Names are unique per workspace.


        ATH-212 — `channel: in_app` additionally requires the
        `feature.messaging.in_app_campaigns` flag.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/CampaignInput"
                - type: object
                  required: [name, subject, body]
      responses:
        "201":
          description: Created (draft)
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/Campaign" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /workspaces/{workspaceId}/campaigns/{campaignId}:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - name: campaignId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    get:
      tags: [Campaigns]
      operationId: getCampaign
      summary: One campaign with its counters (campaigns.manage)
      description: >
        Scoped to the workspace in the path — a member of another
        workspace gets 403, never another tenant's rows.
      responses:
        "200":
          description: The campaign
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/Campaign" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Campaigns]
      operationId: updateCampaign
      summary: Edit a draft or scheduled campaign (campaigns.manage)
      description: >
        ATH-211 — content and audience are editable only while the
        campaign is `draft` or `scheduled`; editing one that is
        `sending` or `sent` 422s, because recipients have already been
        claimed against the old content. `status` accepts `paused` (stop
        launching further recipients) and `draft` (un-schedule).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/CampaignInput"
                - type: object
                  properties:
                    status: { type: string, enum: [draft, paused] }
      responses:
        "200":
          description: Updated campaign
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/Campaign" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }
    delete:
      tags: [Campaigns]
      operationId: deleteCampaign
      summary: Delete a campaign that never sent (campaigns.manage)
      description: >
        ATH-211 — only `draft` and `scheduled` campaigns delete; one
        that is sending or has sent is a delivery record and 422s. The
        recipient ledger cascades with the row.
      responses:
        "204": { description: Deleted }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /workspaces/{workspaceId}/campaigns/{campaignId}/send:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - name: campaignId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Campaigns]
      operationId: sendCampaign
      summary: Send now, or schedule (campaigns.manage)
      description: >
        ATH-211 — with no body (or a null `scheduled_at`) the campaign
        moves to `sending` and the fan-out queues immediately; with a
        future `scheduled_at` it moves to `scheduled` and the
        every-minute due-campaign sweep launches it then — never earlier.
        Requires `feature.messaging.campaigns` and a `segment_id`.


        ATH-212 — sending an `in_app` campaign resolves and freezes its
        audience the same way, but queues no delivery work: each
        recipient waits, claimed but pending, until that contact next
        appears in the widget.

        Calling send twice is safe: the transition out of `draft` /
        `scheduled` is a conditional update, so only one caller wins,
        and even a duplicated fan-out job cannot double-send because
        each recipient row is claimed before its send.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                scheduled_at:
                  type: [string, "null"]
                  format: date-time
                  description: Future instant to send at; null/omitted sends now.
      responses:
        "202":
          description: Accepted — sending or scheduled
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/Campaign" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /workspaces/{workspaceId}/subscription:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    get:
      tags: [Billing]
      operationId: getSubscription
      summary: Subscription state for the workspace's organization (billing.manage)
      description: >
        ATH-041 — the billing entity is the organization (Cashier
        customer); this reads plan, trial, and subscription status.
        ATH-042 adds the dunning/grace state: `payment_failed` means an
        invoice failed and Stripe is retrying, `dunning_grace_ends_at`
        is when access lapses if it never lands. Note this is NOT
        `on_grace_period`, which is Cashier's post-cancellation window.
      responses:
        "200":
          description: Subscription state
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    required: [plan_id, on_trial, trial_ends_at, customer_linked, subscribed, status, quantity, on_grace_period, ends_at, payment_failed, dunning_since, dunning_grace_ends_at]
                    properties:
                      plan_id: { type: string }
                      on_trial: { type: boolean }
                      trial_ends_at: { type: [string, "null"], format: date-time }
                      customer_linked: { type: boolean }
                      subscribed: { type: boolean }
                      status: { type: [string, "null"] }
                      quantity: { type: [integer, "null"] }
                      on_grace_period: { type: boolean }
                      ends_at: { type: [string, "null"], format: date-time }
                      payment_failed: { type: boolean }
                      dunning_since: { type: [string, "null"], format: date-time }
                      dunning_grace_ends_at: { type: [string, "null"], format: date-time }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  # ── Shortcuts + reminders (ATH-133, Doc 04 §4.2) ────────────────────────
  # ATH-013 — these SEVEN endpoints shipped without spec entries, and the
  # codegen-diff gate structurally cannot notice: it proves the generated
  # types match the spec, never that the spec matches the routes. The
  # spec↔route parity test added with this ticket is what found them.
  /workspaces/{workspaceId}/conversations/{conversationId}/ban:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - { name: conversationId, in: path, required: true, schema: { $ref: "#/components/schemas/Ulid" } }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Inbox]
      operationId: banConversationVisitor
      summary: Ban the widget visitor behind a conversation (inbox.configure)
      description: >
        ATH-013 — shipped undocumented; found by the spec↔route parity
        test. Requires `inbox.configure`. Bans the VISITOR, not the
        conversation: the thread stays, the person can't start new ones.
        422 when the conversation has no widget visitor (an email thread
        has nobody to ban).
      responses:
        "200":
          description: Visitor banned
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    required: [banned]
                    properties:
                      banned: { type: boolean }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /workspaces/{workspaceId}/shortcuts:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    get:
      tags: [Inbox]
      operationId: listShortcuts
      summary: Canned replies for the composer's !bang expansion (inbox.view)
      description: >
        Requires `inbox.view` — every agent needs them. Scoped to the
        workspace in the path. `q` prefix-matches the bang.
      parameters:
        - { name: q, in: query, required: false, schema: { type: string, maxLength: 32 } }
      responses:
        "200":
          description: Up to 25 shortcuts, ordered by bang
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      required: [id, bang, title, body]
                      properties:
                        id: { $ref: "#/components/schemas/Ulid" }
                        bang: { type: string }
                        title: { type: string }
                        body: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Inbox]
      operationId: createShortcut
      summary: Create a canned reply (inbox.configure)
      description: >
        Requires `inbox.configure` — reading shortcuts is every agent's
        business, authoring them is configuration.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [bang, title, body]
              properties:
                bang: { type: string, maxLength: 32, pattern: "^[a-z0-9_-]+$" }
                title: { type: string, maxLength: 255 }
                body: { type: string, maxLength: 5000 }
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    required: [id]
                    properties:
                      id: { $ref: "#/components/schemas/Ulid" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /workspaces/{workspaceId}/shortcuts/{shortcutId}:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - { name: shortcutId, in: path, required: true, schema: { $ref: "#/components/schemas/Ulid" } }
    delete:
      tags: [Inbox]
      operationId: deleteShortcut
      summary: Delete a canned reply (inbox.configure)
      description: Requires `inbox.configure`. Scoped to the workspace in the path.
      responses:
        "204": { description: Deleted }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /workspaces/{workspaceId}/reminders:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    get:
      tags: [Inbox]
      operationId: listReminders
      summary: The caller's own follow-up reminders (inbox.view)
      description: >
        Requires `inbox.view`. Reminders are PERSONAL: every route here
        operates on the calling user's reminders only, never the
        workspace's. Due processing is `cairn:process-reminders`.
      responses:
        "200":
          description: Up to 100 reminders, soonest first
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      required: [id, conversation_id, remind_at, note]
                      properties:
                        id: { $ref: "#/components/schemas/Ulid" }
                        conversation_id: { $ref: "#/components/schemas/Ulid" }
                        remind_at: { type: string, format: date-time }
                        note: { type: [string, "null"] }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Inbox]
      operationId: createReminder
      summary: Remind me about a conversation later (inbox.reply)
      description: Requires `inbox.reply`. The reminder belongs to the CALLER.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [conversation_id, remind_at]
              properties:
                conversation_id: { $ref: "#/components/schemas/Ulid" }
                remind_at: { type: string, format: date-time, description: Must be in the future. }
                note: { type: [string, "null"], maxLength: 500 }
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    required: [id]
                    properties:
                      id: { $ref: "#/components/schemas/Ulid" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /workspaces/{workspaceId}/reminders/{reminderId}:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - { name: reminderId, in: path, required: true, schema: { $ref: "#/components/schemas/Ulid" } }
    delete:
      tags: [Inbox]
      operationId: deleteReminder
      summary: Cancel one of your reminders (inbox.reply)
      description: Requires `inbox.reply`. Only the caller's own reminders are reachable.
      responses:
        "204": { description: Deleted }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /workspaces/{workspaceId}/invoices:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    get:
      tags: [Billing]
      operationId: getInvoices
      summary: Stripe-hosted invoice history (billing.manage)
      description: >
        ATH-046 — invoices come from Stripe, and so do the documents:
        `hosted_url` and `pdf` are Stripe-hosted links (Doc 01 §6.2
        "Stripe-hosted invoice PDFs"). We never render an invoice
        ourselves. Returns an empty list — not an error — when the org
        has no Stripe customer yet, so the billing page renders on a
        fresh install.
      responses:
        "200":
          description: Invoices, newest first
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      required: [id, total_cents, currency, status, created_at]
                      properties:
                        id: { type: string }
                        number: { type: [string, "null"] }
                        total_cents: { type: integer }
                        currency: { type: string }
                        status: { type: [string, "null"] }
                        created_at: { type: string, format: date-time }
                        hosted_url: { type: [string, "null"] }
                        pdf: { type: [string, "null"] }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /workspaces/{workspaceId}/billing-portal:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Billing]
      operationId: createBillingPortalSession
      summary: Stripe billing portal session (billing.manage)
      description: >
        ATH-046 — payment methods and tax IDs are handled by Stripe's
        hosted billing portal, never by us. Card data must never touch
        this application: no card form, no PAN in a request body, no PCI
        surface. Returns the URL to redirect the owner to.
      responses:
        "200":
          description: Portal URL
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    required: [url]
                    properties:
                      url: { type: string, format: uri }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409":
          description: The organization has no Stripe customer yet (no_customer)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "503":
          description: Stripe is not configured (billing_unconfigured)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /workspaces/{workspaceId}/credits/topup:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    get:
      tags: [Billing]
      operationId: getTopUpPacks
      summary: Available AI credit top-up packs (billing.manage)
      description: >
        ATH-045 — the one-time packs a workspace can buy. `purchasable`
        is false until the founder provisions that pack's Stripe price;
        the UI should hide rather than offer a checkout that would 503.
      responses:
        "200":
          description: Packs
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      required: [id, price_cents, credit_cents, purchasable]
                      properties:
                        id: { type: string }
                        price_cents: { type: integer }
                        credit_cents: { type: integer }
                        purchasable: { type: boolean }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Billing]
      operationId: purchaseTopUp
      summary: Buy a credit top-up pack via one-time Checkout (billing.manage)
      description: >
        ATH-045 — returns a hosted Stripe Checkout URL (one-time
        payment, not a subscription). Credit is granted by the
        checkout.session.completed webhook once the session is actually
        PAID, never here — this endpoint only opens the till.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [pack]
              properties:
                pack: { type: string }
      responses:
        "200":
          description: Hosted Checkout URL
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    required: [url]
                    properties:
                      url: { type: string, format: uri }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ValidationError" }
        "502":
          description: Stripe rejected the checkout
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "503":
          description: Stripe is not configured (billing_unconfigured)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /workspaces/{workspaceId}/credits/auto-recharge:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    get:
      tags: [Billing]
      operationId: getAutoRecharge
      summary: Auto-recharge settings (billing.manage)
      description: >
        ATH-045 — threshold-triggered auto-recharge against the org's
        saved default payment method. `card_on_file` is false when there
        is nothing to charge off-session, in which case enabling this
        will silently never fire — surface that in the UI.
      responses:
        "200":
          description: Auto-recharge settings
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    required: [enabled, threshold_cents, pack, card_on_file, cooldown_minutes]
                    properties:
                      enabled: { type: boolean }
                      threshold_cents: { type: [integer, "null"] }
                      pack: { type: [string, "null"] }
                      card_on_file: { type: boolean }
                      cooldown_minutes: { type: integer }
                      last_recharged_at: { type: [string, "null"], format: date-time }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    put:
      tags: [Billing]
      operationId: updateAutoRecharge
      summary: Update auto-recharge settings (billing.manage)
      description: >
        ATH-045 — enabling requires both a threshold and a pack: a
        half-configured auto-recharge that silently never fires is worse
        than one that is plainly off.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [enabled]
              properties:
                enabled: { type: boolean }
                threshold_cents: { type: [integer, "null"], minimum: 0, maximum: 100000 }
                pack: { type: [string, "null"] }
      responses:
        "200":
          description: Updated settings
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    properties:
                      enabled: { type: boolean }
                      threshold_cents: { type: [integer, "null"] }
                      pack: { type: [string, "null"] }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /workspaces/{workspaceId}/limits:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    get:
      tags: [Billing]
      operationId: getLimits
      summary: Plan limit usage (billing.manage)
      description: >
        ATH-047 — per-limit usage against the plan's ceiling. `exceeded`
        is the read-only state a downgrade can produce: the data stays
        (nothing is ever deleted), but creation is blocked until the
        workspace is back under the ceiling or upgrades. `limit: null`
        means uncapped.
      responses:
        "200":
          description: Limit usage keyed by limit name
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    additionalProperties:
                      type: object
                      required: [usage, limit, remaining, exceeded]
                      properties:
                        usage: { type: integer }
                        limit: { type: [integer, "null"] }
                        remaining: { type: [integer, "null"] }
                        exceeded: { type: boolean }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /workspaces/{workspaceId}/seats:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    get:
      tags: [Billing]
      operationId: getSeats
      summary: Seat usage and allowance (billing.manage)
      description: >
        ATH-043 — seats are per workspace: `allowance` = the plan's
        included seats + any extra seats purchased on the Stripe
        subscription. `occupied` counts members plus pending
        invitations, so a seat is held from the moment it is offered.
        `allowance: null` means uncapped (Enterprise custom terms).
      responses:
        "200":
          description: Seat state
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    required: [included, purchased, allowance, occupied, available, supports_extra_seats, extra_seat_price_cents]
                    properties:
                      included: { type: [integer, "null"] }
                      purchased: { type: integer }
                      allowance: { type: [integer, "null"] }
                      occupied: { type: integer }
                      available: { type: [integer, "null"] }
                      supports_extra_seats: { type: boolean }
                      extra_seat_price_cents: { type: [integer, "null"] }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Billing]
      operationId: updateSeats
      summary: Set the number of purchased extra seats (billing.manage)
      description: >
        ATH-043 — sets the extra-seat subscription item's quantity on
        Stripe, prorated. `quantity` is the ABSOLUTE number of extra
        seats wanted (not a delta), so a retried request is idempotent.
        Only plans with an extra-seat price support this (Scale); other
        plans get 422 and should upgrade instead. Cannot be set below
        the seats currently occupied beyond the included allowance.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [quantity]
              properties:
                quantity: { type: integer, minimum: 0, maximum: 500 }
      responses:
        "200":
          description: Updated seat state
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    properties:
                      purchased: { type: integer }
                      allowance: { type: [integer, "null"] }
                      occupied: { type: integer }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ValidationError" }
        "502":
          description: Stripe rejected the quantity change
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "503":
          description: Stripe is not configured (billing_unconfigured)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /workspaces/{workspaceId}/subscription/checkout:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Billing]
      operationId: startCheckout
      summary: Start a Stripe Checkout session to subscribe (billing.manage)
      description: >
        Requires `billing.manage`. Scoped to the workspace in the path —
        a member of another workspace gets 403, never another tenant’s
        rows.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [plan]
              properties:
                plan: { type: string, enum: [starter, growth, scale] }
      responses:
        "200":
          description: Hosted Checkout URL
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    properties:
                      url: { type: string, format: uri }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ValidationError" }
        "502": { description: Stripe rejected the checkout }
        "503": { description: Stripe not configured (founder infra pending) }

  /workspaces/{workspaceId}/credits:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    get:
      tags: [Billing]
      operationId: getCreditBalance
      summary: AI credit balance + recent ledger entries (billing.manage)
      description: >
        ATH-044 — the current AI-credit balance (from the append-only
        ledger), whether the plan is unlimited, and recent grants/debits
        for the billing UI and top-up upsell.
      responses:
        "200":
          description: Balance
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    required: [unlimited, balance_micros, balance_cents, entries]
                    properties:
                      unlimited: { type: boolean }
                      balance_micros: { type: integer }
                      balance_cents: { type: integer }
                      entries:
                        type: array
                        items:
                          type: object
                          required: [id, delta_cents, balance_cents, reason, created_at]
                          properties:
                            id: { $ref: "#/components/schemas/Ulid" }
                            delta_cents: { type: number }
                            balance_cents: { type: number }
                            reason: { type: string }
                            created_at: { type: [string, "null"], format: date-time }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /hooks/stripe:
    post:
      tags: [Billing]
      operationId: stripeWebhook
      summary: Stripe webhook receiver (ATH-044)
      description: >
        Signature-verified + idempotently deduped. Handles the billing
        lifecycle: invoice.paid (monthly AI-credit grant + clears
        dunning), invoice.payment_failed (dunning/grace), the
        customer.subscription.* mirror, and checkout.session.completed /
        async_payment_succeeded / async_payment_failed for one-time
        credit top-ups (ATH-016 — async methods settle after checkout).
        Not a client API.
      responses:
        "200": { description: Processed (or already processed) }
        "400": { description: Invalid signature }
        "503": { description: Webhook secret not configured }

  /hooks/tls/authorize:
    get:
      tags: [KnowledgeBase]
      operationId: tlsAuthorize
      summary: On-demand-TLS issuance authorization (ATH-093)
      description: >
        Called by the help-center ingress (Caddy on_demand_tls "ask" or a
        platform equivalent) before minting a certificate. 200 iff the
        hostname has a VERIFIED custom-domain row. Shared-secret token in
        the query because the ask directive sends no headers; the token
        unset disables the endpoint (fail closed). Not a client API.
      security: []
      parameters:
        - { name: domain, in: query, required: true, schema: { type: string } }
        - { name: token, in: query, required: true, schema: { type: string } }
      responses:
        "200":
          description: Issue the certificate
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    required: [authorized]
                    properties:
                      authorized: { type: boolean }
        # Quoted on purpose: an unquoted comma inside a flow mapping is
        # the exact YAML bug that broke this lint for months (ATH-004).
        "401": { description: "Missing/invalid token, or endpoint disabled" }
        "404": { description: Hostname has no verified domain row — do not issue }

  /workspaces/{workspaceId}/crawls:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    get:
      tags: [Knowledge]
      operationId: listCrawls
      summary: List website crawls and their status (ai.configure)
      description: >
        Requires `ai.configure`. Scoped to the workspace in the path — a
        member of another workspace gets 403, never another tenant’s
        rows.
      responses:
        "200":
          description: Crawl configs
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/CrawlConfig" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Knowledge]
      operationId: startCrawl
      summary: Start a website crawl (ai.configure)
      description: >
        ATH-152 — points the agent at a domain. The crawl runs on the
        queue (robots-aware, sitemap-seeded, depth/page capped) and its
        pages become public knowledge. Throttled 20/min.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [root_url]
              properties:
                root_url: { type: string, format: uri, maxLength: 2048 }
                max_pages: { type: integer, minimum: 1, maximum: 2000, default: 100 }
                max_depth: { type: integer, minimum: 0, maximum: 10, default: 3 }
      responses:
        "202":
          description: Crawl queued
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/CrawlConfig" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /workspaces/{workspaceId}/crawls/{crawlId}:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - name: crawlId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    delete:
      tags: [Knowledge]
      operationId: deleteCrawl
      summary: Delete a crawl config (ai.configure)
      description: >
        Requires `ai.configure`. Scoped to the workspace in the path — a
        member of another workspace gets 403, never another tenant’s
        rows.
      responses:
        "204": { description: Deleted }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /workspaces/{workspaceId}/uploads:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    get:
      tags: [Knowledge]
      operationId: listUploads
      summary: List uploaded knowledge documents (ai.configure)
      description: >
        Requires `ai.configure`. Scoped to the workspace in the path — a
        member of another workspace gets 403, never another tenant’s
        rows.
      responses:
        "200":
          description: Uploads
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/KnowledgeUpload" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Knowledge]
      operationId: uploadDocument
      summary: Upload a document for ingestion (ai.configure)
      description: >
        ATH-153 — PDF/DOCX/MD/HTML/TXT up to 20 MB. The file is stored
        and an ingest job extracts, chunks, and embeds it. Throttled
        60/min.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file: { type: string, format: binary }
                title: { type: string, maxLength: 255 }
                audience: { type: string, enum: [public, customers, internal], default: public }
      responses:
        "202":
          description: Upload accepted for ingestion
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/KnowledgeUpload" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /workspaces/{workspaceId}/uploads/{sourceId}:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - name: sourceId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    delete:
      tags: [Knowledge]
      operationId: deleteUpload
      summary: Delete an uploaded document and its chunks (ai.configure)
      description: >
        Requires `ai.configure`. Scoped to the workspace in the path — a
        member of another workspace gets 403, never another tenant’s
        rows.
      responses:
        "204": { description: Deleted }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /workspaces/{workspaceId}/analytics/support:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - { name: from, in: query, schema: { type: string, format: date } }
      - { name: to, in: query, schema: { type: string, format: date } }
    get:
      tags: [Analytics]
      operationId: getSupportDashboard
      summary: Support dashboard metrics (analytics.view)
      description: >
        Requires `analytics.view`. Scoped to the workspace in the path —
        a member of another workspace gets 403, never another tenant’s
        rows.
      responses:
        "200":
          description: Support metrics
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    allOf:
                      - $ref: "#/components/schemas/AnalyticsDashboard"
                      - type: object
                        required: [by_channel]
                        properties:
                          by_channel: { $ref: "#/components/schemas/AnalyticsBreakdown" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /workspaces/{workspaceId}/analytics/content:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - { name: from, in: query, schema: { type: string, format: date } }
      - { name: to, in: query, schema: { type: string, format: date } }
    get:
      tags: [Analytics]
      operationId: getContentDashboard
      summary: Content dashboard metrics (analytics.view)
      description: >
        Requires `analytics.view`. Scoped to the workspace in the path —
        a member of another workspace gets 403, never another tenant’s
        rows.
      responses:
        "200":
          description: Content metrics
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    allOf:
                      - $ref: "#/components/schemas/AnalyticsDashboard"
                      - type: object
                        required: [top_search_terms, zero_result_terms]
                        properties:
                          top_search_terms: { $ref: "#/components/schemas/AnalyticsBreakdown" }
                          zero_result_terms: { $ref: "#/components/schemas/AnalyticsBreakdown" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /workspaces/{workspaceId}/analytics/ai:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - { name: from, in: query, schema: { type: string, format: date } }
      - { name: to, in: query, schema: { type: string, format: date } }
    get:
      tags: [Analytics]
      operationId: getAiDashboard
      summary: AI dashboard metrics (analytics.view)
      description: >
        ATH-259, Doc 06 §3.3. Requires `analytics.view`; 404 below
        `feature.analytics.dashboard` (the Growth+ plan entitlement). Every number is derived from an
        existing source rather than a parallel counter: run outcomes come
        from `agent_runs` (ATH-160), credit spend from the `credit_ledger`
        debits (ATH-166), and the deflection block from the ATH-285 ROI
        rollup read through the Metrics port. Scoped to the workspace in
        the path — a member of another workspace gets 403, never another
        tenant's rows.
      responses:
        "200":
          description: AI metrics
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/AiDashboard" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /workspaces/{workspaceId}/analytics/deflection-roi:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - { name: from, in: query, schema: { type: string, format: date } }
      - { name: to, in: query, schema: { type: string, format: date } }
    get:
      tags: [Analytics]
      operationId: getDeflectionRoiDashboard
      summary: Per-article deflection-ROI dashboard (analytics.view)
      description: >
        PA-226 (ATH-304), Doc 07 §3 — the content team's value made
        measurable: per article, the conversations it deflected, the
        agent-minutes that saved, and the question-cluster volume it now
        covers. Requires `analytics.view`; 404 below
        `feature.mining.roi_dashboard` (rule 7). Every number is DERIVED
        from the ATH-285 deflection-ROI rollup read through the Metrics
        port (`deflection.by_source`, `deflection.total`,
        `deflection.minutes`) — this surface recomputes nothing and never
        scans `agent_runs` or `events`. Agent-minutes-saved is avoided
        conversations × a configurable average handle time
        (`handle_minutes_per_contact`, `CAIRN_DEFLECTION_HANDLE_MINUTES`),
        surfaced alongside the multiplier so the math is auditable.
        `top_intents` ranks the deflected question themes by volume;
        `revenue_signal_available` is false and every intent's
        `revenue_linked` is false because the platform has no
        per-conversation revenue signal yet — the ranking is the honest
        volume proxy for "revenue-touching intents", not a fabricated
        revenue number. Scoped to the workspace in the path — a member of
        another workspace gets 403, never another tenant's rows.
      responses:
        "200":
          description: Deflection-ROI dashboard
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/DeflectionRoiDashboard" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /workspaces/{workspaceId}/analytics/nudges:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - { name: from, in: query, schema: { type: string, format: date } }
      - { name: to, in: query, schema: { type: string, format: date } }
    get:
      tags: [Analytics]
      operationId: getNudgeTuningDashboard
      summary: Proactive-guidance nudge tuning dashboard (analytics.view)
      description: >
        PA-229 (Doc 07 §4) — the tuning dashboard over the nudge funnel:
        shown → engaged → walkthrough started → helped → conversation avoided.
        Requires `analytics.view`; 404 below `feature.guidance.nudges`
        (rule 7). Every count is DERIVED from the `nudge_events` log through
        the metric registry (the same `NudgeEventCountSource` a custom
        dashboard resolves), so this surface owns no parallel counter. The
        rates are arithmetic on those counts (a ratio does not sum across
        days, so it is computed here, not registered). `conversations_avoided`
        is `nudges_shown − conversations_started`: a nudge that did not lead
        the visitor into a support conversation deflected one — the honest
        proxy while the platform has no per-conversation revenue signal.
        Scoped to the workspace in the path — a member of another workspace
        gets 403, never another tenant's rows.
      responses:
        "200":
          description: Nudge funnel metrics + derived rates
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/NudgeTuningDashboard" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /workspaces/{workspaceId}/analytics/metrics:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
    get:
      tags: [Analytics]
      operationId: listDashboardMetrics
      summary: The metric vocabulary a custom dashboard may reference (analytics.view)
      description: >
        ATH-259, Doc 06 §3.4. The whitelist of registered metrics — the
        picker vocabulary. A dashboard references these ids and nothing
        else; an id absent from this list is a 422 on write. Clients must
        not hardcode ids, because this list is what validation measures
        against. 404 below `feature.analytics.custom_dashboards`.
      responses:
        "200":
          description: Registered metrics
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/MetricDefinition" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /workspaces/{workspaceId}/dashboards:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
    get:
      tags: [Analytics]
      operationId: listDashboards
      summary: Saved custom dashboards (analytics.view)
      description: >
        ATH-259, Doc 06 §3.4. The workspace's saved dashboards, newest
        first. Returns the STORED shape (name + referenced metric ids);
        resolving a dashboard's numbers is a separate call, so listing
        never runs every metric in every dashboard. 404 below
        `feature.analytics.custom_dashboards`.
      responses:
        "200":
          description: Saved dashboards
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Dashboard" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      tags: [Analytics]
      operationId: createDashboard
      summary: Save a custom dashboard (analytics.view)
      description: >
        ATH-259, Doc 06 §3.4. `widgets[].metric` must be an id from
        `listDashboardMetrics`; an unregistered id is a 422. Nothing
        user-supplied reaches SQL — a dashboard stores references, never a
        query. 404 below `feature.analytics.custom_dashboards`.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/DashboardInput" }
      responses:
        "201":
          description: Dashboard created
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/Dashboard" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /workspaces/{workspaceId}/dashboards/{dashboardId}:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - { name: dashboardId, in: path, required: true, schema: { $ref: "#/components/schemas/Ulid" } }
    get:
      tags: [Analytics]
      operationId: getDashboard
      summary: One custom dashboard, resolved over a window (analytics.view)
      description: >
        ATH-259, Doc 06 §3.4. Each widget's metric id is resolved back
        through the registry to its source and evaluated over the window;
        `series` widgets additionally carry day buckets. A widget whose
        metric has since been retired from the registry reports
        `available: false` rather than rendering a zero — a removed metric
        must read as "gone", never as "no activity". Another tenant's
        dashboard is a 404.
      parameters:
        - { name: from, in: query, schema: { type: string, format: date } }
        - { name: to, in: query, schema: { type: string, format: date } }
      responses:
        "200":
          description: Resolved dashboard
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/ResolvedDashboard" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Analytics]
      operationId: updateDashboard
      summary: Rename or recompose a saved dashboard (analytics.view)
      description: >
        ATH-259, Doc 06 §3.4. Both fields optional; `widgets` carries the
        same registry whitelist as the create path, so an update cannot
        name a metric that resolves to no source.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/DashboardUpdate" }
      responses:
        "200":
          description: Dashboard updated
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/Dashboard" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }
    delete:
      tags: [Analytics]
      operationId: deleteDashboard
      summary: Delete a saved dashboard (analytics.view)
      description: >
        ATH-259, Doc 06 §3.4. Deletes one dashboard in this workspace.
        Another tenant's dashboard is a 404.
      responses:
        "204": { description: Dashboard deleted }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /workspaces/{workspaceId}/knowledge-health:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - $ref: "#/components/parameters/Cursor"
      - $ref: "#/components/parameters/Limit"
      - name: roi_days
        in: query
        required: false
        description: ATH-285 — trailing window (days, ending now) the `roi` block is summed over. Default 30, max 90.
        schema: { type: integer, minimum: 1, maximum: 90, default: 30 }
    get:
      tags: [Knowledge]
      operationId: getKnowledgeHealth
      summary: Knowledge Health — gap clusters, drift, summary (articles.manage)
      description: >
        ATH-281 (Doc 07 §1) — the human-facing surface of the self-healing
        loop. Returns the workspace's open knowledge-gap clusters ranked by
        volume (`data`, cursor-paginated), the guides currently paused for
        selector drift with their step-health context (`drifted_guides`),
        and the triage summary counts (`summary`). Requires
        `articles.manage`; scoped to the workspace in the path — a member of
        another workspace gets 403, never another tenant’s rows. Gated on
        `feature.knowledge.self-healing`: 404 (not 403, so the flag leaks
        nothing) below a design-partner workspace. `summary` and
        `drifted_guides` accompany every page of the cluster cursor.


        ATH-285 (Doc 07 §1 "ROI attribution") adds `roi`: deflected
        contacts over a trailing window, their modelled value, and the
        knowledge sources that earned the credit.
      responses:
        "200":
          description: Knowledge Health snapshot
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/PaginatedList"
                  - type: object
                    required: [data, summary, drifted_guides, roi]
                    properties:
                      data:
                        type: array
                        description: Open gap clusters, ranked by volume (freshest first among equals).
                        items: { $ref: "#/components/schemas/KnowledgeHealthCluster" }
                      summary: { $ref: "#/components/schemas/KnowledgeHealthSummary" }
                      drifted_guides:
                        type: array
                        description: Guides paused as answer sources for selector drift (ATH-284), most recently flagged first.
                        items: { $ref: "#/components/schemas/KnowledgeHealthDriftedGuide" }
                      roi: { $ref: "#/components/schemas/KnowledgeHealthRoi" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /workspaces/{workspaceId}/knowledge-health/clusters/{clusterId}/disposition:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - name: clusterId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    post:
      tags: [Knowledge]
      operationId: dispositionGapCluster
      summary: Triage a gap cluster — mark reviewed/dismissed (articles.manage)
      description: >
        ATH-281 (Doc 07 §1 disposition "c) dismiss") — records a triage
        disposition on a gap cluster so it drops out of the open Knowledge
        Health queue. Requires `articles.manage`. Idempotent: replaying the
        same disposition is a no-op returning the same cluster. The
        capture-request (ATH-282) and AI-draft (ATH-283) dispositions are
        owned by their own tickets and are not part of this endpoint.
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [disposition]
              properties:
                disposition:
                  type: string
                  enum: [reviewed, dismissed]
                  description: reviewed = triaged and acknowledged; dismissed = intentionally ignored. Both remove the cluster from the open queue.
      responses:
        "200":
          description: Cluster dispositioned
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/KnowledgeHealthCluster" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /workspaces/{workspaceId}/knowledge-health/clusters/{clusterId}/draft:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - name: clusterId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    post:
      tags: [Knowledge]
      operationId: draftArticleFromGapCluster
      summary: AI-draft an article from a gap cluster (articles.manage)
      description: >
        ATH-283 (Doc 07 §1 disposition "a) AI-draft article from existing
        partial knowledge") — queues a job that writes a DRAFT article for
        this gap-cluster theme and returns immediately. The draft is
        grounded in the cluster's member questions (what people actually
        asked) and in existing knowledge retrieved for the canonical
        question, so it reuses real facts and cites real sources rather
        than inventing them. It lands in the ordinary ATH-090
        draft→review→publish workflow as `status=draft` for a human to
        edit and approve; nothing is published by this call.


        Requires `articles.manage` and is gated on
        `feature.knowledge.self-healing` (404 below the flag, matching the
        rest of the surface). Drafting spends AI credits: the response is
        202 whether the job was newly queued or was already in flight, and
        a cluster that already has a pending or created draft is NEVER
        drafted twice — the repeat call returns the existing draft
        reference untouched. A workspace out of credits gets 402.
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      responses:
        "202":
          description: Draft queued, or the cluster's existing draft when one was already requested
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/KnowledgeHealthClusterDraft" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402":
          description: AI credits exhausted (credits_exhausted) — top up before drafting
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /workspaces/{workspaceId}/knowledge-health/clusters/{clusterId}/capture-request:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - name: clusterId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    post:
      tags: [Knowledge]
      operationId: requestCaptureForGapCluster
      summary: Turn a gap cluster into a capture request (articles.manage)
      description: >
        ATH-282 (Doc 07 §1 disposition "b) CAPTURE REQUEST → assignment") —
        the complement to AI-drafting. Some themes cannot be answered from
        existing knowledge because nobody has written the flow down yet;
        those need a human to RECORD it, not a model to paraphrase what
        isn't there. This creates a work order against the cluster,
        optionally assigned to a workspace member, and returns a deep link
        that opens the browser extension primed to record for it.


        Requires `articles.manage` and is gated on
        `feature.knowledge.self-healing` (404 below the flag, matching the
        rest of the surface). A cluster carries at most ONE live capture
        request: a second call while one is open or in progress returns
        that request with 200 instead of creating a duplicate work order,
        so two reviewers clicking the same row cannot assign the same
        recording twice. A dismissed request releases the theme, which may
        then be requested again.
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                assignee_id:
                  description: >
                    Workspace member to assign the recording to. Must be a
                    member of this workspace; anything else is 422. Omit to
                    create the request unassigned and assign it later.
                  oneOf:
                    - { $ref: "#/components/schemas/Ulid" }
                    - type: "null"
                title:
                  type: [string, "null"]
                  maxLength: 200
                  description: >
                    What to record. Defaults to the cluster's canonical
                    question — the thing people actually kept asking.
      responses:
        "200":
          description: The cluster's existing live capture request — no duplicate was created
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/CaptureRequest" }
        "201":
          description: Capture request created
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/CaptureRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /workspaces/{workspaceId}/capture-requests:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - $ref: "#/components/parameters/Cursor"
      - $ref: "#/components/parameters/Limit"
      - name: status
        in: query
        required: false
        description: Filter to one lifecycle state. Omit for every request in the workspace.
        schema: { type: string, enum: [open, in_progress, completed, dismissed] }
      - name: assignee_id
        in: query
        required: false
        description: Filter to one assignee — the SME's own queue of recordings to make.
        schema: { $ref: "#/components/schemas/Ulid" }
    get:
      tags: [Knowledge]
      operationId: listCaptureRequests
      summary: List capture requests (articles.manage)
      description: >
        ATH-282 (Doc 07 §1) — the workspace's capture work orders, newest
        first, cursor-paginated. Requires `articles.manage` and is gated on
        `feature.knowledge.self-healing` (404 below the flag).
      responses:
        "200":
          description: Capture requests
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/PaginatedList"
                  - type: object
                    required: [data]
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/CaptureRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /workspaces/{workspaceId}/capture-requests/{requestId}:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - name: requestId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    get:
      tags: [Knowledge]
      operationId: getCaptureRequest
      summary: Read one capture request (guides.create)
      description: >
        ATH-282 (Doc 07 §1) — reads a single work order. This is the
        endpoint the browser extension calls to VERIFY a capture-request
        deep link before it starts recording: the id arrives from a web
        page, which is untrusted input, so the extension resolves it
        against the API using the signed-in user's own token. A request
        belonging to another workspace is a 404 here, which is precisely
        what makes a forged or stale deep link inert.


        Gated on `guides.create` rather than `articles.manage`: the
        assignee is an SME who records the flow, and requiring the
        content-maintenance permission would lock the deep link out for
        exactly the person it was sent to.
      responses:
        "200":
          description: Capture request
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/CaptureRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Knowledge]
      operationId: updateCaptureRequest
      summary: Assign, dismiss or complete a capture request (articles.manage)
      description: >
        ATH-282 (Doc 07 §1) — moves a work order through its lifecycle.
        `assignee_id` (re)assigns it; `status` transitions it. Requires
        `articles.manage` and is gated on `feature.knowledge.self-healing`.


        Transitions are one-way out of the terminal states: `completed` and
        `dismissed` requests reject further changes with 422, so a closed
        work order cannot be silently reopened or reassigned. Dismissing
        RELEASES the gap cluster, which becomes requestable again;
        completing links the resulting guide back to the cluster, which is
        how the loop closes. `in_progress` is normally reached by the
        extension starting a recording rather than by this endpoint.
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              minProperties: 1
              properties:
                status:
                  type: string
                  enum: [open, in_progress, completed, dismissed]
                assignee_id:
                  description: Workspace member to assign to; null unassigns.
                  oneOf:
                    - { $ref: "#/components/schemas/Ulid" }
                    - type: "null"
                guide_id:
                  description: >
                    The recorded guide that answers this request. Required
                    when transitioning to `completed` by hand; the extension
                    path sets it automatically at finalize.
                  oneOf:
                    - { $ref: "#/components/schemas/Ulid" }
                    - type: "null"
      responses:
        "200":
          description: Capture request updated
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/CaptureRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /workspaces/{workspaceId}/ai-shadow:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    get:
      tags: [Knowledge]
      operationId: getShadowReport
      summary: Shadow-mode enablement report (ai.view_runs)
      description: >
        ATH-172 — how the AI would have handled real conversations in
        shadow mode, with AI-draft vs human-reply pairs to review before
        enabling the agent live.
      responses:
        "200":
          description: Shadow report
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    properties:
                      total: { type: integer }
                      compared: { type: integer }
                      by_outcome:
                        type: object
                        properties:
                          answered: { type: integer }
                          escalated: { type: integer }
                          no_answer: { type: integer }
                      pairs:
                        type: array
                        items:
                          type: object
                          properties:
                            id: { $ref: "#/components/schemas/Ulid" }
                            outcome: { type: string }
                            # PA-324 — was [string, "null"], alone among every
                            # other confidence in this spec (all number/float).
                            # The server sends the classifier's float; a string
                            # here was a typo, not a different concept.
                            confidence: { type: [number, "null"], format: float }
                            ai_draft: { type: [string, "null"] }
                            human_reply: { type: [string, "null"] }
                            created_at: { type: [string, "null"], format: date-time }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /workspaces/{workspaceId}/ai-settings:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    get:
      tags: [Knowledge]
      operationId: getAiSettings
      summary: The AI agent's configuration (ai.configure)
      description: >
        Requires `ai.configure`. Scoped to the workspace in the path — a
        member of another workspace gets 403, never another tenant’s
        rows.
      responses:
        "200":
          description: Agent profile
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/AgentProfile" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    put:
      tags: [Knowledge]
      operationId: updateAiSettings
      summary: Update the AI agent's configuration (ai.configure)
      description: >
        ATH-164 — persona, custom instructions, the on/active/review
        mode, and a settings bag (answer length, blocked topics,
        languages, escalation rules).


        PA-271 — `settings` MERGES on update, and here is what that means
        exactly, because the previous one-line claim was not the whole
        behaviour. A key you send replaces the stored value for that key;
        keys you omit are left alone, so a partial update never clobbers
        the rest of the bag. `blocked_topics` and `languages` are lists
        and are replaced WHOLESALE, never merged element-wise — send the
        list you want stored, and send `[]` to clear it. Objects, should
        this bag ever grow one, merge key-by-key at every depth, and
        `null` retires a key.


        A key that is not in `AgentSettings` is REJECTED with 422 (it
        names the offending keys), not stored and not silently dropped.
        This is a deliberate behaviour change: before PA-271 such keys
        were discarded without any signal, so a typo'd key looked like a
        successful save that did nothing.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/AgentProfileInput" }
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/AgentProfile" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /workspaces/{workspaceId}/email-settings:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    get:
      tags: [Inbox]
      operationId: getEmailSettings
      summary: Inbound address + custom-domain DNS wizard (ATH-136)
      description: >
        Requires `settings.manage`. Scoped to the workspace in the path
        — a member of another workspace gets 403, never another tenant’s
        rows.
      responses:
        "200":
          description: Email channel settings
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    additionalProperties: false
                    required: [inbound_address, custom_domain]
                    properties:
                      # PA-298 — null until inbound email infrastructure
                      # exists. It previously read `{token}@inbound.athena.app`,
                      # a domain owned by a third party.
                      inbound_address: { type: [string, "null"] }
                      # PA-300 — this object used to document `verified:
                      # boolean` and `host`, neither of which the controller
                      # has EVER sent (checked back to ATH-136), while the
                      # `status` and `name` it does send were undocumented.
                      # Nothing caught it because everything here was optional
                      # and open — which is why it is now required and closed.
                      custom_domain:
                        type: object
                        additionalProperties: false
                        required: [status, dns_records]
                        properties:
                          status:
                            type: string
                            enum: [unconfigured]
                            description: >
                              A staged status, not a boolean, for the same
                              reason PA-299 gave the domain flow one: this
                              flow has more than two states. Only
                              `unconfigured` exists until the provider
                              infrastructure lands; new states arrive here
                              with the code that emits them (rule 1).
                          # PA-298 — EMPTY until the sending infrastructure
                          # exists. These are records a customer adds to their
                          # OWN zone: an SPF `include:` authorises the named
                          # domain to send as them, so inventing one delegates
                          # their email authorisation to a stranger.
                          dns_records:
                            type: array
                            items:
                              type: object
                              additionalProperties: false
                              required: [purpose, type, name, value]
                              properties:
                                purpose: { type: string, enum: [SPF, DKIM, Return-Path] }
                                type: { type: string }
                                # `name` is what registrars, dig and the
                                # DigitalOcean API all call a record's
                                # left-hand side; `host` was the spec's
                                # invention and was never on the wire.
                                name: { type: string }
                                value: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /hooks/email/inbound/{token}:
    post:
      tags: [Inbox]
      operationId: emailInboundWebhook
      summary: Provider inbound-email webhook (ATH-136)
      description: >
        Postmark-shape payload; authenticated by the shared provider
        secret in X-Cairn-Email-Token (constant-time compare). The
        token path segment routes to the workspace. Not a client API.
      parameters:
        - name: token
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: Postmark inbound webhook shape.
              additionalProperties: true
      responses:
        "204": { description: Accepted (idempotent on MessageID redelivery) }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /hooks/email/status:
    post:
      tags: [Inbox]
      operationId: emailStatusWebhook
      summary: Provider delivery/bounce callback (ATH-136)
      description: >
        No session: the credential in the request (widget key,
        invitation token, or webhook signature) names the tenant and is
        verified server-side.
      parameters: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: true
      responses:
        "204": { description: Processed }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /hooks/telegram/inbound/{workspaceId}:
    post:
      tags: [Inbox]
      operationId: telegramInboundWebhook
      summary: Telegram Bot API webhook (ATH-182)
      description: >
        Telegram Update objects (message / edited_message). The
        workspace id in the path routes the update — the founder
        registers this exact URL via setWebhook, one bot per workspace —
        and the per-workspace secret in X-Telegram-Bot-Api-Secret-Token
        authenticates Telegram (constant-time compare against the vault
        credential). 404 when the channel is not activated (no vault
        credential, or the omnichannel plan gate is off); 401 on a bad
        secret. Telegram sends no delivery receipts, so there is no
        status sibling. Not a client API.
      parameters:
        - name: workspaceId
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: Telegram Bot API Update shape.
              additionalProperties: true
      responses:
        "204": { description: Accepted (idempotent on update_id redelivery) }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /hooks/whatsapp/inbound/{workspaceId}:
    get:
      tags: [Inbox]
      operationId: whatsappVerifyWebhook
      summary: WhatsApp Cloud API webhook verification (ATH-180)
      description: >
        Meta's GET verification handshake. Echoes hub.challenge (text/plain)
        when hub.mode=subscribe and hub.verify_token matches the workspace's
        vault verify_token (constant-time compare). The workspace id in the
        path routes the check — the founder registers this exact URL as the
        app's callback, one phone number per workspace. 404 when the channel
        is not activated (no vault credential, or the omnichannel plan gate
        is off); 403 on a bad or missing verify token. Not a client API.
      parameters:
        - name: workspaceId
          in: path
          required: true
          schema: { type: string }
        - name: hub.mode
          in: query
          required: false
          schema: { type: string }
        - name: hub.verify_token
          in: query
          required: false
          schema: { type: string }
        - name: hub.challenge
          in: query
          required: false
          schema: { type: string }
      responses:
        "200": { description: Challenge echoed verbatim (text/plain) }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      tags: [Inbox]
      operationId: whatsappInboundWebhook
      summary: WhatsApp Cloud API events webhook (ATH-180)
      description: >
        Meta's entry[].changes[].value envelope carrying messages[] (inbound
        text/media) and/or statuses[] (sent/delivered/read/failed delivery
        receipts). The workspace id in the path routes the event; the payload
        is authenticated by X-Hub-Signature-256 — HMAC-SHA256 over the raw
        body with the workspace app_secret, constant-time compare. 404 when
        the channel is not activated (no vault credential, or the omnichannel
        plan gate is off); 401 on a bad or missing signature. Redelivery is
        idempotent through the message wamid. Not a client API.
      parameters:
        - name: workspaceId
          in: path
          required: true
          schema: { type: string }
        - name: X-Hub-Signature-256
          in: header
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: WhatsApp Cloud API webhook envelope.
              additionalProperties: true
      responses:
        "204": { description: Accepted (idempotent on message wamid redelivery) }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /hooks/messenger/inbound/{workspaceId}:
    get:
      tags: [Inbox]
      operationId: messengerVerifyWebhook
      summary: Meta Messenger webhook verification (ATH-181)
      description: >
        Meta's GET verification handshake (shared by every Messenger
        Platform product). Echoes hub.challenge (text/plain) when
        hub.mode=subscribe and hub.verify_token matches the workspace's
        vault verify_token (constant-time compare). The workspace id in the
        path routes the check — the founder registers this exact URL as the
        Meta app's Messenger callback, one Facebook Page per workspace. 404
        when the channel is not activated (no vault credential, or the
        omnichannel plan gate is off); 403 on a bad or missing verify token.
        Not a client API.
      parameters:
        - name: workspaceId
          in: path
          required: true
          schema: { type: string }
        - name: hub.mode
          in: query
          required: false
          schema: { type: string }
        - name: hub.verify_token
          in: query
          required: false
          schema: { type: string }
        - name: hub.challenge
          in: query
          required: false
          schema: { type: string }
      responses:
        "200": { description: Challenge echoed verbatim (text/plain) }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      tags: [Inbox]
      operationId: messengerInboundWebhook
      summary: Meta Messenger events webhook (ATH-181)
      description: >
        Meta's entry[].messaging[] envelope (object=page) carrying message
        events (text/attachments), delivery receipts (delivery.mids), and
        read watermarks (read.watermark). The workspace id in the path
        routes the event; the payload is authenticated by X-Hub-Signature-256
        — HMAC-SHA256 over the raw body with the workspace app_secret,
        constant-time compare. 404 when the channel is not activated (no
        vault credential, or the omnichannel plan gate is off); 401 on a bad
        or missing signature. Redelivery is idempotent through the message
        mid. Not a client API.
      parameters:
        - name: workspaceId
          in: path
          required: true
          schema: { type: string }
        - name: X-Hub-Signature-256
          in: header
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: Meta Messenger Platform webhook envelope (object=page).
              additionalProperties: true
      responses:
        "204": { description: Accepted (idempotent on message mid redelivery) }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /hooks/instagram/inbound/{workspaceId}:
    get:
      tags: [Inbox]
      operationId: instagramVerifyWebhook
      summary: Meta Instagram DM webhook verification (ATH-181)
      description: >
        Meta's GET verification handshake for the Instagram messaging
        product — identical to the Messenger handshake but keyed on the
        workspace's Instagram vault credential. Echoes hub.challenge
        (text/plain) when hub.mode=subscribe and hub.verify_token matches the
        vault verify_token (constant-time compare). The founder registers
        this exact URL as the Meta app's Instagram callback, one Instagram
        professional account per workspace. 404 when the channel is not
        activated; 403 on a bad or missing verify token. Not a client API.
      parameters:
        - name: workspaceId
          in: path
          required: true
          schema: { type: string }
        - name: hub.mode
          in: query
          required: false
          schema: { type: string }
        - name: hub.verify_token
          in: query
          required: false
          schema: { type: string }
        - name: hub.challenge
          in: query
          required: false
          schema: { type: string }
      responses:
        "200": { description: Challenge echoed verbatim (text/plain) }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      tags: [Inbox]
      operationId: instagramInboundWebhook
      summary: Meta Instagram DM events webhook (ATH-181)
      description: >
        Meta's entry[].messaging[] envelope (object=instagram) carrying
        message events (text/attachments) and read receipts (read.mid — the
        Instagram read event names the specific message, unlike Messenger's
        watermark). The workspace id in the path routes the event; the
        payload is authenticated by X-Hub-Signature-256 — HMAC-SHA256 over
        the raw body with the workspace app_secret, constant-time compare.
        404 when the channel is not activated (no vault credential, or the
        omnichannel plan gate is off); 401 on a bad or missing signature.
        Redelivery is idempotent through the message mid. Not a client API.
      parameters:
        - name: workspaceId
          in: path
          required: true
          schema: { type: string }
        - name: X-Hub-Signature-256
          in: header
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: Meta Messenger Platform webhook envelope (object=instagram).
              additionalProperties: true
      responses:
        "204": { description: Accepted (idempotent on message mid redelivery) }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /hooks/twilio/inbound/{workspaceId}:
    post:
      tags: [Inbox]
      operationId: twilioInboundWebhook
      summary: Twilio Programmable Messaging inbound webhook (ATH-183)
      description: >
        Twilio's inbound SMS/MMS webhook. UNLIKE the other channels this
        body is application/x-www-form-urlencoded, not JSON — Twilio posts
        From, To, Body, MessageSid, and NumMedia / MediaUrl{N} +
        MediaContentType{N} for MMS. The sender's From phone (E.164) is the
        contact key and the thread; MessageSid is the channel_message_id
        (redelivery dedupe). The workspace id in the path routes the message
        — the founder registers this exact URL on the Twilio number's
        Messaging configuration, one number per workspace — and the payload
        is authenticated by X-Twilio-Signature (HMAC-SHA1 over the full URL
        plus the alphabetically-sorted POST params, base64, keyed by the
        workspace auth token; constant-time compare). 404 when the channel
        is not activated (no vault credential, or the omnichannel plan gate
        is off); 401 on a bad or missing signature. Not a client API.
      parameters:
        - name: workspaceId
          in: path
          required: true
          schema: { type: string }
        - name: X-Twilio-Signature
          in: header
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              description: >
                Twilio inbound message parameters (form-encoded). Representative
                keys below; Twilio sends additional fields (SmsSid, NumSegments,
                geodata) which the adapter ignores.
              properties:
                MessageSid: { type: string }
                From: { type: string }
                To: { type: string }
                Body: { type: string }
                NumMedia: { type: string }
                MediaUrl0: { type: string }
                MediaContentType0: { type: string }
              additionalProperties: true
      responses:
        "204": { description: Accepted (idempotent on MessageSid redelivery) }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /hooks/twilio/status/{workspaceId}:
    post:
      tags: [Inbox]
      operationId: twilioStatusWebhook
      summary: Twilio Programmable Messaging status callback (ATH-183)
      description: >
        Twilio's delivery status callback for an outbound message, also
        application/x-www-form-urlencoded: MessageSid + MessageStatus
        (queued/sent/delivered/undelivered/failed). The workspace id in the
        path routes the callback — the founder sets this exact URL as the
        StatusCallback on the Twilio number / Messaging Service — and it is
        authenticated by X-Twilio-Signature exactly as the inbound webhook.
        delivered/read move the message forward; undelivered/failed record a
        delivery failure; the weaker queued/sending/sent are ignored (the
        optimistic delivered_at the send already stamped stands). 404 when
        the channel is not activated; 401 on a bad or missing signature. Not
        a client API.
      parameters:
        - name: workspaceId
          in: path
          required: true
          schema: { type: string }
        - name: X-Twilio-Signature
          in: header
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              description: Twilio status callback parameters (form-encoded).
              properties:
                MessageSid: { type: string }
                MessageStatus: { type: string }
                ErrorCode: { type: string }
              additionalProperties: true
      responses:
        "204": { description: Accepted (no-op for statuses that carry no modeled state) }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /hooks/twilio/voice/{workspaceId}:
    post:
      tags: [Inbox]
      operationId: twilioVoiceWebhook
      summary: Twilio Programmable Voice answer webhook (ATH-184)
      description: >
        Twilio's inbound CALL webhook — Programmable Voice, not Messaging, so
        it is a separate callback URL from `/hooks/twilio/inbound`. The body
        is application/x-www-form-urlencoded (CallSid, From, To, CallStatus,
        Direction) and the RESPONSE IS TwiML (text/xml), not a 204: Twilio
        plays whatever this returns to the caller. The call becomes a
        conversation on the `phone` channel, keyed on the caller's From phone
        (thread key `voice:<E.164>`, deliberately distinct from SMS's
        `sms:<E.164>` so a call and a text from the same number never share a
        thread), with CallSid as the channel_message_id — Twilio redelivers
        the same CallSid until it gets a 2xx, so redelivery is a no-op.


        RECORDING IS OFF UNLESS THE WORKSPACE OPTED IN — the
        `feature.messaging.call_recording` flag AND the
        `settings.voice.recording_enabled` workspace setting must BOTH be
        true. When they are, the returned TwiML always speaks a recording
        announcement to the caller before the `<Record>` verb; when they are
        not, the TwiML answers and hangs up and no audio is ever requested.


        Authenticated by X-Twilio-Signature exactly as the Messaging hooks.
        Unlike them this route answers 200 with a refusal TwiML — never 404 —
        for an unknown, suspended, unactivated or un-entitled workspace: a
        404 makes Twilio play its own "application error" to a live human,
        and one identical spoken refusal for every such case reveals nothing
        a 404 would not. 401 on a bad or missing signature (the caller is not
        Twilio, so there is nobody to speak to). Not a client API.
      parameters:
        - name: workspaceId
          in: path
          required: true
          schema: { type: string }
        - name: X-Twilio-Signature
          in: header
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              description: >
                Twilio inbound voice parameters (form-encoded). Representative
                keys below; Twilio sends additional fields (AccountSid,
                CallerName, geodata) which the adapter ignores.
              properties:
                CallSid: { type: string }
                From: { type: string }
                To: { type: string }
                CallStatus: { type: string }
                Direction: { type: string }
              additionalProperties: true
      responses:
        "200":
          description: >
            TwiML the caller hears. Either an answer + recording announcement
            + `<Record>`, an answer + hangup (recording off), or the refusal
            + hangup (workspace not activated).
          content:
            text/xml:
              schema:
                type: string
                description: TwiML `<Response>` document.
        "401": { $ref: "#/components/responses/Unauthorized" }

  /hooks/twilio/voice/recording/{workspaceId}:
    post:
      tags: [Inbox]
      operationId: twilioVoiceRecordingWebhook
      summary: Twilio Programmable Voice recording-status callback (ATH-184)
      description: >
        Twilio's recordingStatusCallback, fired when a call recording is
        available: form-encoded CallSid, RecordingSid, RecordingUrl,
        RecordingDuration, RecordingStatus. Only ever reached when the
        workspace opted into recording, because only then does the answer
        webhook above emit a `<Record>` carrying this URL — and the consent
        gate is re-checked HERE anyway, so a callback that arrives after the
        opt-in was withdrawn stores and transcribes nothing.


        A `completed` callback queues the ingest job, which downloads the
        audio into workspace-scoped media, transcribes it through the ATH-222
        voice-to-text seam, and writes the transcript as a message on the
        call's conversation. RecordingSid is that message's
        channel_message_id, so a redelivered callback is a no-op rather than
        a second transcript.


        Authenticated by X-Twilio-Signature exactly as the other Twilio
        hooks. 404 when the channel is not activated and 401 on a bad or
        missing signature — the SAME posture as the Messaging hooks, and
        correct here for the reason it is wrong on the answer webhook
        above: this peer is a machine throughout, so there is no caller on
        a line to speak a refusal to. 204 for everything it accepts AND for
        an activated workspace that has not opted into recording or sent
        nothing to ingest, both of which are real acceptances rather than
        errors Twilio could act on. Not a client API.
      parameters:
        - name: workspaceId
          in: path
          required: true
          schema: { type: string }
        - name: X-Twilio-Signature
          in: header
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              description: Twilio recording status callback parameters (form-encoded).
              properties:
                CallSid: { type: string }
                RecordingSid: { type: string }
                RecordingUrl: { type: string }
                RecordingDuration: { type: string }
                RecordingStatus: { type: string }
              additionalProperties: true
      responses:
        "204": { description: Accepted (idempotent on RecordingSid redelivery; also the answer when recording is not enabled) }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /hooks/slack/events:
    post:
      tags: [Integrations]
      operationId: slackEventsWebhook
      summary: Slack Events API receiver (ATH-254)
      description: >
        The ONE callback URL for the Cairn Slack app, shared by every
        workspace — unlike the omnichannel receivers above, there is no
        workspace id in the path. Slack addresses one app, not one
        tenant, so the `team_id` in the body selects the installation
        and the workspace is derived from it. A team with no live
        installation is accepted and dropped (204), never 404: the
        response must not tell an unauthenticated caller which Slack
        teams are connected to Cairn.


        AUTHENTICATION is Slack's request signature and nothing else
        ('hooks/*' authz exemption, no session, no key). `v0=` HMAC-SHA256
        over the literal bytes `v0:{X-Slack-Request-Timestamp}:{raw body}`
        against the app's signing secret, compared with `hash_equals`.
        The timestamp is INSIDE the MAC, so a captured request cannot be
        re-stamped and still verify; it is additionally rejected outside
        a ±300s window. 300s is both Slack's own published
        recommendation and ATH-251's `WebhookSignature::TOLERANCE_SECONDS`,
        so the platform has ONE replay window rather than two.


        The signing secret is APP-level (config), not per-workspace: it
        belongs to the Cairn Slack app we publish. Per-workspace bot
        tokens are a different secret and live in the encrypted vault.


        Verification happens before the body is interpreted, so
        `url_verification` challenges are echoed only when signed. Real
        events are acknowledged immediately and processed on a queue —
        Slack retries anything it cannot get a response to within 3s,
        and a duplicate reply into a customer conversation is worse than
        a slow one. Not a client API.
      parameters:
        - name: X-Slack-Signature
          in: header
          required: true
          schema: { type: string }
        - name: X-Slack-Request-Timestamp
          in: header
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: Slack Events API envelope (url_verification or event_callback).
              properties:
                type: { type: string }
                team_id: { type: string }
                challenge: { type: string }
              additionalProperties: true
      responses:
        "200":
          description: >
            The `url_verification` challenge echo. Only this envelope
            type answers with a body.
          content:
            application/json:
              schema:
                type: object
                properties:
                  challenge: { type: string }
        "204": { description: Event accepted (or deliberately dropped — see the description) }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /hooks/ecommerce/{platform}:
    parameters:
      - name: platform
        in: path
        required: true
        description: The e-commerce platform this receiver serves. Only `shopify` sends webhooks.
        schema: { type: string, enum: [shopify] }
    post:
      tags: [Integrations]
      operationId: ecommerceWebhook
      summary: E-commerce platform webhook receiver (ATH-256)
      description: >
        ATH-256 — the ONE callback URL for the Cairn app on a platform,
        shared by every install, like ATH-254's Slack receiver and unlike
        the per-tenant omnichannel hooks: there is no workspace id in the
        path. Shopify signs on behalf of the app, so `X-Shopify-Shop-Domain`
        in the delivery selects the connection and the workspace is derived
        from it. A shop with no live connection is accepted and dropped
        (204), never 404 — the response must not tell an unauthenticated
        caller which stores are Cairn customers.


        `{platform}` selects the adapter. WooCommerce sends no webhooks and
        has no receiver, so `/hooks/ecommerce/woocommerce` is 404.


        AUTHENTICATION is the platform's request signature and nothing else
        ('hooks/*' authz exemption, no session, no key). `X-Shopify-Hmac-Sha256`
        is base64(HMAC-SHA256(raw body, app secret)), compared with
        `hash_equals` over the VERBATIM body bytes. Unlike Slack, Shopify
        does NOT put a timestamp inside the MAC, so replay is bounded by
        DEDUP on `X-Shopify-Webhook-Id` (a redelivery is a no-op), not by a
        narrow time window: `X-Shopify-Triggered-At` is only an OUTER
        freshness bound (default 7 days, comfortably past Shopify's ~48h
        retry schedule) past which the dedup ledger can no longer be relied
        on. 401 on a bad, missing or stale signature.


        WHAT IT DOES is lifecycle only. `app/uninstalled` and the mandatory
        `shop/redact` destroy the vault credential and revoke the row;
        `customers/redact` and `customers/data_request` are acknowledged
        and do nothing, because this integration stores no order or
        customer data to erase or export — GDPR compliance here is a
        genuine no-op. Order webhooks are not subscribed to. Not a client
        API.
      parameters:
        - name: X-Shopify-Hmac-Sha256
          in: header
          required: true
          schema: { type: string }
        - name: X-Shopify-Shop-Domain
          in: header
          required: true
          schema: { type: string }
        - name: X-Shopify-Topic
          in: header
          required: true
          schema: { type: string }
        - name: X-Shopify-Webhook-Id
          in: header
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: The platform's webhook payload; read for its topic and id only, never stored.
              additionalProperties: true
      responses:
        "204":
          description: >
            Delivery accepted — processed, deduped or deliberately dropped
            (see the description).
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /workspaces/{workspaceId}/inboxes:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    get:
      tags: [Inbox]
      operationId: listInboxes
      summary: Sub-inboxes with routing rules
      description: >
        Requires `inbox.configure`. Scoped to the workspace in the path
        — a member of another workspace gets 403, never another tenant’s
        rows.
      responses:
        "200":
          description: All inboxes
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/SubInbox" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Inbox]
      operationId: createInbox
      summary: Create a sub-inbox
      description: >
        Requires `inbox.configure`. Scoped to the workspace in the path
        — a member of another workspace gets 403, never another tenant’s
        rows.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SubInboxInput" }
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/SubInbox" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /workspaces/{workspaceId}/inboxes/{inboxId}:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - name: inboxId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    patch:
      tags: [Inbox]
      operationId: updateInbox
      summary: Update a sub-inbox / its routing rules
      description: >
        Requires `inbox.configure`. Scoped to the workspace in the path
        — a member of another workspace gets 403, never another tenant’s
        rows.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SubInboxInput" }
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/SubInbox" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }
    delete:
      tags: [Inbox]
      operationId: deleteInbox
      summary: Delete a sub-inbox (conversations fall back to none)
      description: >
        Requires `inbox.configure`. Scoped to the workspace in the path
        — a member of another workspace gets 403, never another tenant’s
        rows.
      responses:
        "204": { description: Deleted }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ── Ticketing (Doc 04 §7, ATH-214) ──────────────────────────────────────
  /workspaces/{workspaceId}/tickets:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    get:
      tags: [Ticketing]
      operationId: listTickets
      summary: Agents' ticket queue (cursor-paginated)
      description: >
        Requires `tickets.manage`; 404s below `feature.support.ticketing`.
        Returns only conversations with `kind=ticket` — the same rows the
        Inbox list serves, filtered. Ordered newest-first on the ULID
        primary key, which is both time-ordered and NOT NULL. The obvious
        alternative, `last_message_at`, is nullable — a freshly opened
        ticket has no message yet — and NULLs sort to opposite ends under
        sqlite and Postgres, so a cursor page would break differently in
        test and in production.
      parameters:
        - { name: state, in: query, schema: { type: string, enum: [pending, open, snoozed, resolved] } }
        - { name: priority, in: query, schema: { type: string, enum: [low, normal, high, urgent] } }
        - { name: assignee_id, in: query, schema: { type: string } }
        - { name: breached, in: query, description: "Only tickets whose first-response or resolution SLA has been marked breached", schema: { type: boolean } }
        - { $ref: "#/components/parameters/Cursor" }
        - { $ref: "#/components/parameters/Limit" }
      responses:
        "200":
          description: A page of tickets
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/TicketSummary" }
                  meta:
                    type: object
                    properties:
                      next_cursor: { type: [string, "null"] }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Ticketing]
      operationId: createTicket
      summary: Open a ticket on behalf of a contact
      description: >
        Requires `tickets.manage`. Creates a conversation with
        `kind=ticket`, allocates the per-workspace reference, and
        computes the SLA due dates from the priority. The opening
        message, when supplied, goes through the ordinary message write
        path so the thread is identical to any other conversation.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/TicketInput" }
      responses:
        "201":
          description: Opened
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/Ticket" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /workspaces/{workspaceId}/tickets/{ticketId}:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - name: ticketId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    get:
      tags: [Ticketing]
      operationId: getTicket
      summary: One ticket with its SLA state
      description: >
        Requires `tickets.manage`. Scoped to the workspace in the path —
        a member of another workspace gets 404, never another tenant’s
        rows. Read the thread through the Inbox messages endpoint.
      responses:
        "200":
          description: The ticket
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/Ticket" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Ticketing]
      operationId: updateTicket
      summary: Update priority, subject, custom fields, or portal visibility
      description: >
        Requires `tickets.manage`. Changing `priority` RECOMPUTES the SLA
        due dates from the new target, unless the request also sets them
        explicitly. Assignment and state transitions are deliberately
        absent — they ride the existing Inbox `assign` and `state`
        endpoints, because a ticket is a conversation.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/TicketUpdateInput" }
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/Ticket" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  # ── Status page (ATH-215, Doc 04 §8) ────────────────────────────────
  /workspaces/{workspaceId}/status-page:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    get:
      tags: [StatusPage]
      operationId: getStatusPage
      summary: The workspace's status-page settings
      description: >
        Requires `settings.manage`; 404s below `feature.support.status_page`.
        The settings row is created on first read, so a workspace that has
        never touched the status page still gets a well-formed (unpublished)
        payload rather than a 404.
      responses:
        "200":
          description: The settings
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/StatusPageSettings" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [StatusPage]
      operationId: updateStatusPage
      summary: Publish or rebrand the status page
      description: >
        Requires `settings.manage`. `published` is the master switch for
        the public surface — while it is false the public page 404s on
        every host, which is what makes "set the page up first, announce
        it later" possible. Any write here rotates the public page's
        full-page cache version.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/StatusPageSettingsInput" }
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/StatusPageSettings" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /workspaces/{workspaceId}/status-page/components:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    get:
      tags: [StatusPage]
      operationId: listStatusComponents
      summary: Components in display order (cursor-paginated)
      description: >
        Requires `settings.manage`. Ordered by `position` then by the
        ULID primary key — both NOT NULL, because a cursor page ordered
        on a nullable column pages differently under sqlite and Postgres.
        Unlike the public page this returns unpublished components too.
      parameters:
        - { $ref: "#/components/parameters/Cursor" }
        - { $ref: "#/components/parameters/Limit" }
      responses:
        "200":
          description: A page of components
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/StatusComponent" }
                  meta:
                    type: object
                    properties:
                      next_cursor: { type: [string, "null"] }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [StatusPage]
      operationId: createStatusComponent
      summary: Add a component
      description: "Requires `settings.manage`. A component is a thing customers recognise — API, Widget, Dashboard."
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/StatusComponentInput" }
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/StatusComponent" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /workspaces/{workspaceId}/status-page/components/{componentId}:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - name: componentId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    patch:
      tags: [StatusPage]
      operationId: updateStatusComponent
      summary: Rename a component or set its current status
      description: >
        Requires `settings.manage`. Scoped to the workspace in the path —
        another tenant's component id 404s, never resolves. Setting
        `published` to false removes the component from the public page
        without deleting its history.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/StatusComponentPatchInput" }
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/StatusComponent" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }
    delete:
      tags: [StatusPage]
      operationId: deleteStatusComponent
      summary: Remove a component
      description: "Requires `settings.manage`. Incident impact rows referencing it are removed with it."
      responses:
        "204": { description: Deleted }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /workspaces/{workspaceId}/status-page/incidents:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    get:
      tags: [StatusPage]
      operationId: listStatusIncidents
      summary: Incident history (cursor-paginated)
      description: >
        Requires `settings.manage`. Newest-first on the ULID primary key,
        which is time-ordered AND NOT NULL; `started_at` would sort NULLs
        to opposite ends under sqlite and Postgres.
      parameters:
        - { name: status, in: query, schema: { type: string, enum: [investigating, identified, monitoring, resolved] } }
        - { name: active, in: query, description: "Only incidents that are not yet resolved", schema: { type: boolean } }
        - { $ref: "#/components/parameters/Cursor" }
        - { $ref: "#/components/parameters/Limit" }
      responses:
        "200":
          description: A page of incidents
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/StatusIncidentSummary" }
                  meta:
                    type: object
                    properties:
                      next_cursor: { type: [string, "null"] }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [StatusPage]
      operationId: createStatusIncident
      summary: Declare an incident
      description: >
        Requires `settings.manage`. Opens at `investigating` unless a
        status is given, records the first update from `body`, and emits
        `status.incident_opened` so a workflow can notify however the
        workspace wants — the same detection/response split ATH-214 used
        for SLA breaches, rather than a bespoke notifier.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/StatusIncidentInput" }
      responses:
        "201":
          description: Declared
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/StatusIncident" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /workspaces/{workspaceId}/status-page/incidents/{incidentId}:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - name: incidentId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    get:
      tags: [StatusPage]
      operationId: getStatusIncident
      summary: One incident with its full update timeline
      description: "Requires `settings.manage`. Scoped to the workspace in the path — a member of another workspace gets 404, never another tenant's rows."
      responses:
        "200":
          description: The incident
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/StatusIncident" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [StatusPage]
      operationId: updateStatusIncident
      summary: Edit an incident's title, impact, affected components, or visibility
      description: >
        Requires `settings.manage`. Deliberately does NOT move the
        lifecycle status — a status change is an UPDATE with a written
        explanation, so it goes through the updates endpoint. Editing an
        incident silently while customers watch is the failure mode a
        status page exists to prevent.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/StatusIncidentPatchInput" }
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/StatusIncident" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /workspaces/{workspaceId}/status-page/incidents/{incidentId}/updates:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - name: incidentId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [StatusPage]
      operationId: appendStatusIncidentUpdate
      summary: Append an update and move the incident's lifecycle
      description: >
        Requires `settings.manage`. Updates are APPEND-ONLY — the
        timeline a customer read yesterday must still say what it said.
        Posting `status=resolved` stamps `resolved_at` and, for an
        incident a monitor opened, releases that monitor's claim so a
        future outage can open a new one. Emits `status.incident_updated`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/StatusIncidentUpdateInput" }
      responses:
        "201":
          description: Appended
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/StatusIncident" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /workspaces/{workspaceId}/status-page/monitors:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    get:
      tags: [StatusPage]
      operationId: listStatusMonitors
      summary: Uptime monitors (cursor-paginated)
      description: "Requires `settings.manage`. Newest-first on the ULID primary key."
      parameters:
        - { $ref: "#/components/parameters/Cursor" }
        - { $ref: "#/components/parameters/Limit" }
      responses:
        "200":
          description: A page of monitors
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/StatusMonitor" }
                  meta:
                    type: object
                    properties:
                      next_cursor: { type: [string, "null"] }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [StatusPage]
      operationId: createStatusMonitor
      summary: Add an uptime monitor
      description: >
        Requires `settings.manage`. The `url` is workspace-supplied and
        therefore attacker-controllable, so it is validated with ATH-202's
        OutboundUrlGuard DNS-free rules at authoring time — a loopback,
        link-local, internal-suffix, credential-bearing, or non-http(s)
        URL is 422 here rather than a request made later. Resolution-time
        checks still run on every sweep, because DNS can change after a
        save. See the tag description for the residual risk.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/StatusMonitorInput" }
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/StatusMonitor" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /workspaces/{workspaceId}/status-page/monitors/{monitorId}:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - name: monitorId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    patch:
      tags: [StatusPage]
      operationId: updateStatusMonitor
      summary: Edit or pause a monitor
      description: "Requires `settings.manage`. A changed `url` is re-validated against the SSRF guard exactly as on create."
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/StatusMonitorPatchInput" }
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/StatusMonitor" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }
    delete:
      tags: [StatusPage]
      operationId: deleteStatusMonitor
      summary: Remove a monitor
      description: "Requires `settings.manage`. Recorded check results go with it; any incident it opened stays, because that incident is public history."
      responses:
        "204": { description: Deleted }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /workspaces/{workspaceId}/status-page/monitors/{monitorId}/checks:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - name: monitorId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    get:
      tags: [StatusPage]
      operationId: listStatusMonitorChecks
      summary: Recorded check results for one monitor (cursor-paginated)
      description: >
        Requires `settings.manage`. Newest-first on the ULID primary key.
        A check that the SSRF guard refused is recorded here as a failure
        carrying the guard's author-facing reason, so a workspace can see
        WHY its monitor never ran instead of watching silent downtime.
      parameters:
        - { $ref: "#/components/parameters/Cursor" }
        - { $ref: "#/components/parameters/Limit" }
      responses:
        "200":
          description: A page of check results
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/StatusMonitorCheck" }
                  meta:
                    type: object
                    properties:
                      next_cursor: { type: [string, "null"] }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /beacon:
    post:
      tags: [Analytics]
      operationId: collectBeacon
      summary: Client analytics beacon (ATH-140)
      description: >
        Public, consent-gated. The widget/help-center/guide pages batch
        events here (keyed by the public widget key). Without consent:true
        nothing is recorded; event names are allowlisted. Not a session API.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [website_id, events]
              properties:
                website_id: { type: string, maxLength: 40 }
                consent: { type: boolean, default: false }
                visitor: { type: string, maxLength: 64, description: anonymous client id }
                events:
                  type: array
                  maxItems: 50
                  items:
                    type: object
                    required: [name]
                    properties:
                      name: { type: string, maxLength: 64 }
                      occurred_at: { type: [string, "null"], format: date-time }
                      properties: { type: object, additionalProperties: true }
      responses:
        "202":
          description: Accepted (some events may be dropped by consent/allowlist)
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    properties:
                      accepted: { type: integer }
                      reason: { type: string }
        "422": { $ref: "#/components/responses/ValidationError" }

  /widget/config:
    get:
      tags: [Widget]
      operationId: getWidgetConfig
      summary: Public widget boot config (brand, availability, theme)
      description: >
        ATH-120 — cached 60s per workspace; `online` is computed from
        working hours per request (up to 60s staleness on the cached
        spec is accepted). white_label is plan-gated and re-checked at
        config build, so downgrades revoke within the TTL.
      parameters:
        - name: website_id
          in: query
          required: true
          schema: { type: string, maxLength: 40 }
      responses:
        "200":
          description: Boot configuration
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    required: [workspace_name, position, online, offline_form, white_label]
                    properties:
                      workspace_name: { type: string }
                      brand:
                        type: object
                        properties:
                          primary_color: { type: string }
                          secondary_color: { type: string }
                          font: { type: [string, "null"] }
                      position: { type: string, enum: [left, right] }
                      launcher_color: { type: [string, "null"] }
                      greeting: { type: [string, "null"] }
                      online: { type: boolean }
                      offline_form: { type: boolean }
                      working_hours: { type: [object, "null"] }
                      white_label: { type: boolean }
                      calls_enabled:
                        type: boolean
                        description: >
                          ATH-262 — whether the visitor-facing call button is
                          shown (feature.messaging.av_calls, re-checked at
                          config time so a flag flip revokes within the cache
                          TTL). Absent/false leaves the button hidden; the
                          server refuses a call either way, so this is UX, not
                          the security boundary.
                      triggers:
                        type: array
                        description: Enabled rules, evaluated client-side (ATH-121)
                        items:
                          type: object
                          properties:
                            id: { $ref: "#/components/schemas/Ulid" }
                            conditions: { type: array, items: { type: object } }
                            message: { type: string }
                      struggle_detection:
                        type: boolean
                        description: >
                          PA-229 (Doc 07 §4) — the resolved enablement
                          (`feature.guidance.nudges` active AND the
                          workspace's struggle_detection_enabled setting).
                          When true the client starts PA-227's struggle
                          detectors and the nudge engine; absent/false leaves
                          them inert. The real field PA-227 deferred.
                      nudges:
                        type: object
                        description: >
                          PA-229 — the client-side nudge tuning delivered with
                          the boot config: the sensitivity slider and the page
                          blocklist. Present regardless of `struggle_detection`
                          (harmless when the detectors are inert); the client
                          reads it only once detection is on.
                        properties:
                          sensitivity: { type: string, enum: [low, medium, high] }
                          page_blocklist: { type: array, items: { type: string } }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }
        "429": { description: Throttled }

  /workspaces/{workspaceId}/widget/settings:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    get:
      tags: [Widget]
      operationId: getWidgetSettings
      summary: Widget presentation settings
      description: >
        Requires `settings.manage`. Scoped to the workspace in the path
        — a member of another workspace gets 403, never another tenant’s
        rows.
      responses:
        "200":
          description: Current settings (defaults when never configured)
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/WidgetSettings" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    put:
      tags: [Widget]
      operationId: updateWidgetSettings
      summary: Update widget presentation settings
      description: >
        Requires `settings.manage`. Scoped to the workspace in the path
        — a member of another workspace gets 403, never another tenant’s
        rows.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WidgetSettings" }
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/WidgetSettings" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /workspaces/{workspaceId}/widget/triggers:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    get:
      tags: [Widget]
      operationId: listWidgetTriggers
      summary: Proactive-message trigger rules
      description: >
        Requires `settings.manage`. Scoped to the workspace in the path
        — a member of another workspace gets 403, never another tenant’s
        rows.
      responses:
        "200":
          description: All rules
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/WidgetTrigger" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Widget]
      operationId: createWidgetTrigger
      summary: Create a trigger rule
      description: >
        Requires `settings.manage`. Scoped to the workspace in the path
        — a member of another workspace gets 403, never another tenant’s
        rows.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WidgetTriggerInput" }
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/WidgetTrigger" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /workspaces/{workspaceId}/widget/triggers/{triggerId}:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - name: triggerId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    patch:
      tags: [Widget]
      operationId: updateWidgetTrigger
      summary: Update a trigger rule
      description: >
        Requires `settings.manage`. Scoped to the workspace in the path
        — a member of another workspace gets 403, never another tenant’s
        rows.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WidgetTriggerInput" }
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/WidgetTrigger" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }
    delete:
      tags: [Widget]
      operationId: deleteWidgetTrigger
      summary: Delete a trigger rule
      description: >
        Requires `settings.manage`. Scoped to the workspace in the path
        — a member of another workspace gets 403, never another tenant’s
        rows.
      responses:
        "204": { description: Deleted }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /widget/installed:
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Widget]
      operationId: pingWidgetInstalled
      summary: Install-detected ping (loader, once per browser)
      description: >
        ATH-123 — the first ping stamps widget_installed_at; later pings
        are no-ops. Keyed by the public website id, throttled.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [website_id]
              properties:
                website_id: { type: string, maxLength: 40 }
      responses:
        "204": { description: Recorded (or already recorded) }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }
        "429": { description: Throttled }

  /workspaces/{workspaceId}/widget/install:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    get:
      tags: [Widget]
      operationId: getWidgetInstall
      summary: Install snippet + detection state (Settings→Widget)
      description: >
        Requires `settings.manage`. Scoped to the workspace in the path
        — a member of another workspace gets 403, never another tenant’s
        rows.
      responses:
        "200":
          description: Snippet with the lazily minted website key
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    required: [website_id, snippet, installed]
                    properties:
                      website_id: { type: string }
                      # PA-298 — null when no widget CDN is configured, which
                      # is the state today. The snippet is a `<script src>`
                      # the customer pastes into their own site, so issuing
                      # one that points at a host we do not control hands
                      # arbitrary JavaScript rights on their page to whoever
                      # answers it. Withholding says "not ready"; issuing says
                      # "paste this at a stranger".
                      snippet: { type: [string, "null"] }
                      installed: { type: boolean }
                      installed_at: { type: [string, "null"], format: date-time }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /helpcenter/search/answer:
    get:
      tags: [KnowledgeBase]
      operationId: getHelpCenterSearchAnswer
      summary: AI answer box for public help-center search (ATH-224)
      security: []
      description: >
        ATH-224 (Doc 05 §7, Doc 03 §5) — a synthesized, cited answer over
        the workspace's PUBLIC published articles, shown above the
        ordinary link results on the hosted help centre.

        Served ONLY on the help-centre host — `{slug}.cairnhelp.com` or
        a verified ATH-093 custom domain — and the route is domain-scoped
        to those hosts. The tenant therefore comes from the host alone:
        there is no bearer token, no workspace id in the path, and no
        widget key, so there is no parameter a caller could change to
        reach another workspace's content.

        Retrieval is pinned to the `public` audience and every hit is
        re-verified against its live article row (published + public)
        before it may ground or be cited, so internal, unlisted,
        authenticated, and draft content can reach neither the answer nor
        the citations.

        Unauthenticated and LLM-backed, so it is the most abusable
        surface in the product: throttled per IP+host, query capped at
        200 characters, answers cached per workspace under the
        help-centre content version, and metered against the workspace
        that owns the help centre (ATH-102/166).

        ADVISORY by contract: the link results come from the
        server-rendered `/search` page and never depend on this call.
        Flag off, plan below the AI suite, exhausted credits, and
        generation failures all return 200 with
        `status: unavailable` — a public site must never show a visitor a
        402 because its owner ran out of credits.
      parameters:
        - name: q
          in: query
          required: true
          schema: { type: string, minLength: 2, maxLength: 200 }
      responses:
        "200":
          description: An answer, an honest refusal, or the answer box switched off
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/HelpCenterAnswer" }
        "404": { description: Host resolves to no help centre }
        "422": { $ref: "#/components/responses/ValidationError" }
        "429": { description: Throttled }

  /widget/helpdesk/search:
    get:
      tags: [Widget]
      operationId: searchWidgetHelpdesk
      summary: Help mode — KB search inside the widget (public articles only)
      description: >
        No session: the credential in the request (widget key,
        invitation token, or webhook signature) names the tenant and is
        verified server-side.
      parameters:
        - name: q
          in: query
          required: true
          schema: { type: string, minLength: 2, maxLength: 200 }
      responses:
        "200":
          description: Top 10 matches
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      required: [slug, title]
                      properties:
                        slug: { type: string }
                        title: { type: string }
                        excerpt: { type: [string, "null"] }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422": { $ref: "#/components/responses/ValidationError" }
        "429": { description: Throttled }

  /widget/helpdesk/articles/{slug}:
    parameters:
      - name: slug
        in: path
        required: true
        schema: { type: string }
    get:
      tags: [Widget]
      operationId: getWidgetHelpdeskArticle
      summary: Published public article for in-widget rendering
      description: >
        No session: the credential in the request (widget key,
        invitation token, or webhook signature) names the tenant and is
        verified server-side.
      responses:
        "200":
          description: Sanitized article HTML
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    required: [slug, title]
                    properties:
                      slug: { type: string }
                      title: { type: string }
                      body: { type: [string, "null"] }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /widget/guides/{guideId}:
    parameters:
      - name: guideId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    get:
      tags: [Widget]
      operationId: getWidgetGuide
      summary: Published guide with steps for the walkthrough player
      description: >
        ATH-270 (Doc 07 §2.1) — the payload the walkthrough runtime
        replays: steps with structured element data and signed media
        URLs, same shape as getGuide. Published guides only, pinned to
        the visitor's workspace. No session: the credential in the
        request (widget key, invitation token, or webhook signature)
        names the tenant and is verified server-side.
      responses:
        "200":
          description: Published guide with steps and signed media URLs
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/Guide" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /widget/page-map:
    get:
      tags: [Widget]
      operationId: getWidgetPageMap
      summary: Published guides relevant to the visitor's current page
      description: >
        PA-229 (Doc 07 §4) — the visitor-token read the client nudge engine
        queries on a struggle signal: the PUBLISHED guides relevant to a page
        URL, ranked pinned-first. It is the widget-facing projection of the
        PA-228 page↔knowledge map (`PageKnowledgeMap`), reusing the SAME
        tolerant `urlMatches` rule so client and server agree on relevance —
        the client never re-derives guide relevance. Only `guide_id` + `title`
        are returned (the toast label + the walkthrough target); the matched
        STEP is resolved client-side from the guide the walkthrough player
        already fetches, so step indices never diverge. Draft/unpublished
        guides are omitted (a nudge must never open a guide the walkthrough
        endpoint would 404). The bearer visitor token names the tenant, so the
        result is pinned to the visitor's workspace (rule 2). Dark below
        `feature.guidance.nudges` — 404, so an off workspace leaks nothing.
      parameters:
        - name: url
          in: query
          required: true
          description: The visitor's current page URL to resolve relevant guides for.
          schema: { type: string, maxLength: 2048 }
      responses:
        "200":
          description: Relevant published guides, pinned first
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/WidgetPageMapEntry" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }
        "429": { description: Throttled }

  /widget/nudge-events:
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Widget]
      operationId: recordWidgetNudgeEvent
      summary: Report one nudge-lifecycle outcome (privacy-light)
      description: >
        PA-229 (Doc 07 §4) — the client reports each step of a nudge's
        lifecycle here so the tuning dashboard can measure the funnel
        (shown → helped → conversation avoided). The bearer visitor token
        names the tenant; a `nudge_events` row is written under it.


        A DEDICATED visitor-token endpoint rather than the beacon collector,
        for two reasons: it is idempotent on the natural `(nudge_id, phase)`
        key — a phase is reported at most once per nudge, so a retry (or the
        Idempotency-Key replay) is a no-op, which the beacon's fire-and-forget
        batch cannot guarantee — and the visitor session authenticates the
        workspace without trusting a client-supplied website key.


        PRIVACY (rule 3, PA-227's stance): a nudge event carries INTERACTION
        METADATA ONLY — the nudge/guide ids, the struggle `kind`, the page
        (stored as origin+path, query and hash dropped), and the lifecycle
        `phase`. It never carries anything the visitor typed or that any
        element showed. Dark below `feature.guidance.nudges` (404).
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WidgetNudgeEventInput" }
      responses:
        "202":
          description: Recorded (or already recorded — idempotent on nudge_id + phase)
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    required: [recorded]
                    properties:
                      recorded:
                        type: boolean
                        description: >
                          True when this call created the row; false when the
                          (nudge_id, phase) pair had already been recorded
                          (idempotent replay).
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }
        "429": { description: Throttled }

  /widget/campaign:
    get:
      tags: [Widget]
      operationId: getWidgetCampaignMessage
      summary: Claim the next in-app campaign message for this visitor
      description: >
        ATH-212 (Doc 04 §6) — an in-app campaign has no push channel, so
        it is delivered when the audience member is next PRESENT in the
        widget. This endpoint is that presence signal: the widget calls
        it once per boot (and again after `user.set` identifies the
        visitor), and the server decides — from the campaign's segment
        audience, which never leaves the server — whether this contact
        has an undelivered in-app campaign waiting.


        When one does, the message is written into the visitor's widget
        conversation like any other message and returned here so the
        widget can surface it proactively. `null` is the ordinary answer
        and means nothing is waiting: no audience match, an anonymous
        visitor, a suppressed contact, a frequency cap, or the
        `feature.messaging.in_app_campaigns` flag being dark for the
        workspace. The widget must treat all of those identically.


        Delivery is claimed per (campaign, contact) before the message
        is written, so calling this repeatedly — across reconnects,
        tabs, devices, or a fresh browser — yields the campaign exactly
        once. A second call returns `null`, not the same message again.
      responses:
        "200":
          description: The claimed campaign message, or null when nothing is waiting
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    oneOf:
                      - $ref: "#/components/schemas/InAppCampaignMessage"
                      - type: "null"
        "401": { $ref: "#/components/responses/Unauthorized" }

  /widget/conversations:
    get:
      tags: [Widget]
      operationId: listWidgetConversations
      summary: The visitor's own conversations
      description: >
        No session: the credential in the request (widget key,
        invitation token, or webhook signature) names the tenant and is
        verified server-side.
      responses:
        "200":
          description: Newest-first, capped at 50
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      required: [id, state]
                      properties:
                        id: { $ref: "#/components/schemas/Ulid" }
                        state: { type: string, enum: [pending, open, snoozed, resolved] }
                        subject: { type: [string, "null"] }
                        last_message_at: { type: [string, "null"], format: date-time }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Widget]
      operationId: createWidgetConversation
      summary: Open a new conversation (widget channel, pending)
      description: >
        No session: the credential in the request (widget key,
        invitation token, or webhook signature) names the tenant and is
        verified server-side.
      responses:
        "201":
          description: Conversation opened; subscribe to `channel` for realtime
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    required: [id, state, channel]
                    properties:
                      id: { $ref: "#/components/schemas/Ulid" }
                      state: { type: string }
                      channel: { type: string, description: Wire channel name to subscribe to }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { description: Throttled }

  /widget/conversations/{conversationId}/messages:
    parameters:
      - name: conversationId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    get:
      tags: [Widget]
      operationId: listWidgetMessages
      summary: Messages in the visitor's conversation (notes excluded)
      description: >
        No session: the credential in the request (widget key,
        invitation token, or webhook signature) names the tenant and is
        verified server-side.
      responses:
        "200":
          description: Oldest-first, capped at 200
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/WidgetMessage" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Widget]
      operationId: sendWidgetMessage
      summary: Send a visitor message
      description: >
        ATH-116 — writes through the shared SendMessage path: reopens a
        resolved conversation, publishes message.created (conversation
        channel) + conversation.updated (workspace inbox) as durable
        fabric events. Attachments land with the AV pipeline (flagged).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [body]
              properties:
                body: { type: string, maxLength: 10000 }
      responses:
        "201":
          description: Sent
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    required: [id]
                    properties:
                      id: { $ref: "#/components/schemas/Ulid" }
                      created_at: { type: [string, "null"], format: date-time }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }
        "429": { description: Throttled }

  /widget/conversations/{conversationId}/typing:
    parameters:
      - name: conversationId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Widget]
      operationId: sendWidgetTyping
      summary: Ephemeral typing signal (with optional live preview)
      description: >
        No session: the credential in the request (widget key,
        invitation token, or webhook signature) names the tenant and is
        verified server-side.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                preview: { type: [string, "null"], maxLength: 200 }
      responses:
        "204": { description: Published (ephemeral — never persisted) }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { description: Throttled }

  /widget/conversations/{conversationId}/read:
    parameters:
      - name: conversationId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Widget]
      operationId: markWidgetConversationRead
      summary: Read receipt — the visitor saw the agent replies
      description: >
        No session: the credential in the request (widget key,
        invitation token, or webhook signature) names the tenant and is
        verified server-side.
      responses:
        "204": { description: Marked }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /widget/conversations/{conversationId}/rate:
    parameters:
      - name: conversationId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Widget]
      operationId: rateWidgetConversation
      summary: CSAT — rate a resolved conversation 1-5
      description: >
        ATH-134 — only resolved conversations accept ratings; re-rating
        updates (the customer's last word wins). Agents receive
        conversation.updated with the score.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [rating]
              properties:
                rating: { type: integer, minimum: 1, maximum: 5 }
      responses:
        "200":
          description: Stored
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      rating: { type: integer }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }
        "429": { description: Throttled }

  /widget/cobrowse/{sessionId}/accept:
    parameters:
      - name: sessionId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Widget]
      operationId: acceptCobrowseSession
      summary: The visitor consents to being watched (co-browse)
      description: >
        ATH-261 (Doc 04 §9) — the visitor's EXPLICIT opt-in. Co-browse
        never starts silently: only after this call does the session go
        `active`, and only then will the frames endpoint relay anything.
        The token in the request scopes the session to this visitor's own
        conversation; another visitor's session id 404s. A session that is
        no longer pending (already ended/expired/declined) 409s.
      responses:
        "200":
          description: Consent recorded; the session is active and frames may stream.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/CobrowseSession" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { description: Throttled }

  /widget/cobrowse/{sessionId}/decline:
    parameters:
      - name: sessionId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Widget]
      operationId: declineCobrowseSession
      summary: The visitor refuses the co-browse request
      description: >
        ATH-261 — the visitor says no. The session terminates as
        `declined` and can never stream; the agent is told via
        `cobrowse.ended`. Refusing is always available and always final.
      responses:
        "204": { description: Declined }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }

  /widget/cobrowse/{sessionId}/frames:
    parameters:
      - name: sessionId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Widget]
      operationId: streamCobrowseFrames
      summary: Relay a batch of masked rrweb frames to the watching agent
      description: >
        ATH-261 — the visitor's browser posts a batch of rrweb events; the
        server relays each as an EPHEMERAL `cobrowse.frame` on the
        conversation channel and stores NONE of it. Rejected with 409
        unless the session is `active` (consent given) and not expired — a
        pre-consent or post-expiry stream carries no frames. Frames are
        already masked client-side (password + `[data-private]`); the
        server never sees raw sensitive values.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [events]
              properties:
                events:
                  type: array
                  maxItems: 500
                  description: rrweb event objects, opaque to the server and relayed verbatim.
                  items:
                    type: object
                    additionalProperties: true
      responses:
        "202": { description: Relayed (ephemeral — never persisted) }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ValidationError" }
        "429": { description: Throttled }

  /widget/cobrowse/{sessionId}/end:
    parameters:
      - name: sessionId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Widget]
      operationId: endWidgetCobrowseSession
      summary: The visitor stops sharing their screen
      description: >
        ATH-261 — the visitor ends the session at any time. Idempotent;
        publishes `cobrowse.ended`. Either party ending is final.
      responses:
        "204": { description: Ended (or already terminal) }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /widget/cobrowse/{sessionId}/guide-capture/consent:
    parameters:
      - name: sessionId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Widget]
      operationId: consentGuideCapture
      summary: The visitor consents to recording this co-browse into a guide
      description: >
        PA-261 (Doc 07 §3, Option B) — the visitor's DISTINCT, explicit
        guide-capture opt-in, separate from the co-browse view consent. Only
        after this call may the recorder ingest steps. The bearer token
        scopes the request to this visitor's own co-browse session; another
        visitor's session id 404s. A request that is no longer pending
        (already consented, or revoked) 409s.
      responses:
        "200":
          description: Consent recorded; the recorder may start ingesting steps.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/CobrowseGuideCapture" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { description: Throttled }

  /widget/cobrowse/{sessionId}/guide-capture/revoke:
    parameters:
      - name: sessionId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Widget]
      operationId: revokeGuideCapture
      summary: The visitor withdraws guide-capture consent
      description: >
        PA-261 — the visitor stops the guide recording at any time.
        Idempotent and always available (never flag-gated), so consent can
        always be withdrawn: every subsequent start/append is refused at
        once. The live co-browse VIEW is unaffected — this revokes only the
        guide recording.
      responses:
        "204": { description: Revoked (or already revoked) }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /widget/cobrowse/{sessionId}/guide-capture/start:
    parameters:
      - name: sessionId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Widget]
      operationId: startGuideCapture
      summary: Open the capture session for a consented co-browse guide recording
      description: >
        PA-261 — the recorder opens a CaptureSession (source=cobrowse, no
        user) for the visitor's masked, structured steps. Fails CLOSED on all
        three gates: the co-browse session must be active, the agent must have
        initiated guide capture, and the visitor must have consented and not
        revoked — missing any is refused (403). Idempotent: the record binds
        to exactly one capture session, so a repeat returns the same one.
        404 below the feature flag.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                app_context: { $ref: "#/components/schemas/AppContext" }
      responses:
        "201":
          description: Capture session opened (or the existing one, on replay).
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/CaptureSession" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /widget/cobrowse/{sessionId}/guide-capture/steps:
    parameters:
      - name: sessionId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Widget]
      operationId: appendGuideCaptureSteps
      summary: Batch-append masked capture steps to a consented guide recording
      description: >
        PA-261 — the recorder appends masked, structured steps (labels only,
        rule 3; selector_candidates required, rule 4), idempotent by
        (session, seq). Fails CLOSED on the same three gates as start,
        re-checked on EVERY batch — so revoking consent stops ingest at once.
        A value-bearing step (raw typed text) is rejected 422 and never
        stored.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [steps]
              properties:
                steps:
                  type: array
                  maxItems: 50
                  items: { $ref: "#/components/schemas/CaptureStep" }
      responses:
        "202": { description: Accepted }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: No recording in progress, or the session is finalized
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /widget/cobrowse/{sessionId}/guide-capture/finalize:
    parameters:
      - name: sessionId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Widget]
      operationId: finalizeGuideCapture
      summary: Finalize the co-browse guide recording and assemble the guide
      description: >
        PA-261 — closes the capture session and hands it to the SAME
        AssembleGuide pipeline any authored capture flows through, yielding an
        editable draft guide in the agent's authoring flow. Refused (403) if
        consent was revoked. Idempotent — re-finalizing returns the same
        guide. A workspace at its plan's guide ceiling gets 402.
      responses:
        "201":
          description: Guide assembly started (or already assembled).
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    required: [guide_id, status]
                    properties:
                      guide_id: { $ref: "#/components/schemas/Ulid" }
                      status: { type: string, enum: [assembling, ready] }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402":
          description: Plan guide limit reached (plan_limit_reached)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /widget/calls:
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Widget]
      operationId: startVisitorCall
      summary: The visitor calls the support team (widget call button)
      description: >
        ATH-262 (Doc 04 §9's widget "call" button) — the OTHER direction.
        The visitor pressing call on their own device, with their own
        token, IS the consent, so the session is created already
        `active` with `consented_at` set and the visitor may mint a token
        at once. An agent still needs `inbox.reply` to mint theirs.


        The conversation must be the caller's own widget conversation;
        any other id 404s. Flag-gated (`feature.messaging.av_calls`) and
        throttled.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [conversation_id]
              properties:
                conversation_id: { $ref: "#/components/schemas/Ulid" }
                media:
                  type: string
                  enum: [audio, video]
                  default: audio
      responses:
        "201":
          description: Ringing; the agents have been notified.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/CallSession" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }
        "429": { description: Throttled }

  /widget/calls/{callId}/accept:
    parameters:
      - name: callId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Widget]
      operationId: acceptCallSession
      summary: The visitor answers the call (the consent gate)
      description: >
        ATH-262 — the visitor's EXPLICIT opt-in, and the ONLY transition
        that makes a call joinable. Until it happens no token is minted
        for anybody, so an agent cannot open a visitor's microphone or
        camera by asking. It can only be driven by the visitor's own
        token; another visitor's call id 404s.


        `media` may NARROW the request but never widen it: a `video`
        request may be answered `audio` (mic only, and the agent's token
        is narrowed too), while answering an `audio` request with `video`
        is 422. A call that is no longer pending 409s.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                media:
                  type: string
                  enum: [audio, video]
                  description: What the visitor is agreeing to. Omit to accept as asked.
      responses:
        "200":
          description: Consent recorded; both sides may now mint a token.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/CallSession" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ValidationError" }
        "429": { description: Throttled }

  /widget/calls/{callId}/decline:
    parameters:
      - name: callId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Widget]
      operationId: declineCallSession
      summary: The visitor refuses the call
      description: >
        ATH-262 — the visitor says no. The call terminates as `declined`
        and can never be joined; the agent is told via `call.ended`.
        Refusing is always available and always final.
      responses:
        "204": { description: Declined }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }

  /widget/calls/{callId}/token:
    parameters:
      - name: callId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Widget]
      operationId: mintVisitorCallToken
      summary: Mint the visitor's short-lived LiveKit join token
      description: >
        ATH-262 — the visitor's join credential, scoped to their own
        identity in this call's room and to nothing else. Refused with 409
        unless the call is joinable (active, consented, unexpired); the
        call id is pinned to this visitor's own calls, so another
        visitor's or another tenant's call 404s. Re-request on reconnect —
        each issue re-checks that consent still stands.
      responses:
        "200":
          description: A join token scoped to this room and identity.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/CallToken" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { description: Throttled }
        "503": { description: No LiveKit deployment is configured for this environment. }

  /widget/calls/{callId}/end:
    parameters:
      - name: callId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Widget]
      operationId: endWidgetCallSession
      summary: The visitor hangs up
      description: >
        ATH-262 — the visitor ends the call at any time. Idempotent;
        publishes `call.ended` and asks LiveKit to delete the room.
        Either party hanging up is final.
      responses:
        "204": { description: Ended (or already terminal) }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /widget/devices:
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Widget]
      operationId: registerVisitorDevice
      summary: Register a mobile device for push notifications
      description: >
        ATH-263 (Doc 04 §2.3) — the mobile SDK registers its APNs
        (`ios`) or FCM (`android`) device token so agent/AI replies reach
        the visitor when the app is backgrounded and no websocket is
        held. The device is always attached to the AUTHENTICATED visitor
        — there is no way to register a device for anybody else — and the
        bearer visitor token names the tenant, exactly like the rest of
        the widget surface.


        The `token` is a push credential: it is stored encrypted at rest,
        never returned in a response, and never logged. Re-registering
        the same token for the same visitor is idempotent (the row is
        upserted, not duplicated), so the SDK may register on every boot
        and after every APNs/FCM token rotation. Flag-gated
        (`feature.messaging.mobile_push`) and throttled.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [platform, token]
              properties:
                platform:
                  type: string
                  enum: [ios, android]
                  description: ios → APNs, android → FCM.
                token:
                  type: string
                  maxLength: 4096
                  description: >
                    The opaque APNs/FCM device token. A push credential —
                    write-only; never echoed back.
      responses:
        "201":
          description: Device registered (or its registration refreshed).
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/VisitorDevice" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }
        "429": { description: Throttled }

  /widget/devices/{deviceId}:
    parameters:
      - name: deviceId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    delete:
      tags: [Widget]
      operationId: unregisterVisitorDevice
      summary: Unregister a mobile device (logout / uninstall)
      description: >
        ATH-263 — drop a device so it stops receiving pushes, e.g. on
        logout. The id is pinned to the authenticated visitor's own
        devices, so another visitor's or another tenant's device id 404s
        rather than 403s — the widget principal cannot even learn it
        exists. Idempotent from the caller's view. Never gated by the
        feature flag: a device already holding pushes must always be able
        to switch them off.
      responses:
        "204": { description: Unregistered (or already gone) }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ── "Do it for me" execution engine, visitor surface (PA-218, Doc 07 §2.2) ─
  # The write surface the agent-driven execution runtime calls: open a run
  # (the SERVER enforces the safety rails — a client check is bypassable),
  # record each executed step, close the run. Visitor-token scoped exactly
  # like the rest of the widget surface — the bearer token names the tenant
  # AND the visitor, and a run belonging to another visitor or workspace
  # 404s. RULE 3: nothing here carries a typed value; a step records WHICH
  # field, never its contents, and a masked/sensitive field is flagged
  # `sensitive_manual` with outcome `skipped_manual`.

  /widget/automation-runs:
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Widget]
      operationId: openAutomationRun
      summary: Open a "do it for me" execution run (the server enforces the safety rails)
      description: >
        PA-218 (Doc 07 §2.2 — "agent performs the steps with confirmation").
        The execution runtime asks to run a published guide's steps on the
        page the visitor is on; THIS endpoint is the security boundary that
        decides whether it may. Requires the `feature.automation.execute`
        flag (404 while dark, rule 7) and a valid visitor token.


        The server enforces, IN ORDER: the feature flag; the domain
        allowlist (PA-219 `AutomationDomainGuard` — the host derived from
        `url` must have a `verified` allowlist row); the sensitive-page rail
        (PA-219 `SensitivePagePolicy` — no execution on payment/password
        pages). A client-side check is bypassable, so the decision is made
        HERE and cannot be talked out of.


        On refusal the run is still AUDITED: an `aborted` run is recorded
        with the matching reason (`domain_not_allowed` or `sensitive_page`)
        and the endpoint returns 422 carrying that reason. On success a
        `running` run is opened and its id returned; the runtime then
        records each executed step against it.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [guide_id, url]
              properties:
                guide_id: { $ref: "#/components/schemas/Ulid" }
                url:
                  type: string
                  maxLength: 2048
                  description: >
                    The visitor's current page URL. Its host is derived for
                    the allowlist check and the whole URL is matched against
                    the sensitive-page rail. Never stored as a run value —
                    only the derived host is recorded.
      responses:
        "201":
          description: A running run was opened.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/AutomationRunHandle" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422":
          description: >
            Execution refused by a safety rail, or the body failed
            validation. On a rail refusal `error.code` is the reason
            (`domain_not_allowed` or `sensitive_page`) and `error.details`
            carries the `run_id` of the audited `aborted` run.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { description: Throttled }

  /widget/automation-runs/{runId}:
    parameters:
      - name: runId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    patch:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Widget]
      operationId: closeAutomationRun
      summary: Close a "do it for me" execution run
      description: >
        PA-218 — move a `running` run to a terminal state: `completed`
        (every step ran), `aborted` (the user stopped it, or a client-side
        rail such as selector ambiguity fired), or `failed`. `abort_reason`
        is required when the status is `aborted` and must be absent
        otherwise.


        Pinned to the authenticated visitor's OWN run — another visitor's or
        another tenant's run id 404s. Deliberately NOT flag-gated: a running
        run must always be closable (the Stop button can never go dark),
        exactly as ATH-263 leaves device unregister ungated. Idempotent — a
        run already terminal is returned unchanged.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [status]
              properties:
                status:
                  type: string
                  enum: [completed, aborted, failed]
                abort_reason:
                  type: string
                  enum: [user_stopped, selector_ambiguous, sensitive_page, domain_not_allowed, error]
                  description: >
                    Required when `status` is `aborted`, optional when
                    `failed`, and forbidden when `completed`.
      responses:
        "200":
          description: The run's terminal state.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/AutomationRunHandle" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }
        "429": { description: Throttled }

  /widget/automation-runs/{runId}/resume:
    parameters:
      - name: runId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Widget]
      operationId: resumeAutomationRun
      summary: Re-authorize a running automation after page navigation
      description: >
        PA-283 — resume the SAME audited "do it for me" run after a full-page
        navigation. Before the client may present the next consent step, the
        server re-runs the execution safety rails against the NEW page: the
        `feature.automation.execute` flag, the verified-domain allowlist, and
        the workspace-sensitive-page policy. A client-side check is bypassable,
        so a persisted cursor alone never authorizes execution on a new page.


        The run is pinned to the authenticated visitor's own workspace and
        `initiator_visitor_ref`; another visitor's or tenant's run id 404s. The
        run must still be `running`, otherwise the endpoint returns 409. On a
        safety-rail refusal the EXISTING run is closed as `aborted` with
        `domain_not_allowed` or `sensitive_page`, and 422 returns that same run
        id. Nothing here carries a typed or pre-filled value: the client stores
        and sends only the run identity, guide identity, next step index, and
        current page URL.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url]
              additionalProperties: false
              properties:
                url:
                  type: string
                  maxLength: 2048
                  description: >
                    The newly loaded page URL. The host is checked against the
                    verified allowlist and the whole URL against the sensitive-
                    page rail. The URL itself is never stored on the run.
      responses:
        "200":
          description: The existing running run is authorized on this page.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/AutomationRunHandle" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { description: The run is already terminal and cannot resume. }
        "422":
          description: >
            The new page failed a safety rail, or the body failed validation.
            On a rail refusal `error.code` is the reason and
            `error.details.run_id` is the existing run that was aborted.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { description: Throttled }

  /widget/automation-runs/{runId}/steps:
    parameters:
      - name: runId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Widget]
      operationId: recordAutomationRunStep
      summary: Record one executed step of a "do it for me" run
      description: >
        PA-218 — append one executed step to a running run. Idempotent on
        `(run, step_index)` (rule 5): a retry of the same index returns the
        row already written and changes nothing. Pinned to the authenticated
        visitor's OWN run — another visitor's or another tenant's run id
        404s. A step on a run that is no longer running is a 409.


        RULE 3 IS THE POINT OF THIS PAYLOAD. There is deliberately NO field
        that could carry a typed value. A step names WHICH selector resolved
        the target (`selector_used`) and THAT an action happened — never the
        text entered. A field the runtime must not type into (masked or
        sensitive) is reported with `sensitive_manual: true` and
        `outcome: skipped_manual`, i.e. "the user did this by hand", never
        its contents. Value-bearing keys (`value`, `text`, `input`, …) are
        rejected with 422, not merely dropped.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [step_index, action, outcome]
              properties:
                step_index:
                  type: integer
                  minimum: 0
                  description: Zero-based position of this step in the run's execution order.
                guide_step_id: { $ref: "#/components/schemas/Ulid" }
                action:
                  type: string
                  enum: [click, fill, navigate, select, submit, scroll, keypress]
                  description: What the runtime did. `fill` NEVER carries the value entered.
                selector_used:
                  type: string
                  maxLength: 2048
                  description: WHICH selector resolved the target — the "where", never the "what".
                outcome:
                  type: string
                  enum: [ok, failed, skipped_manual, aborted]
                sensitive_manual:
                  type: boolean
                  default: false
                  description: True when the field was handed to the user for manual entry (masked/sensitive).
                duration_ms:
                  type: integer
                  minimum: 0
                  default: 0
                screenshot_ref:
                  type: string
                  maxLength: 1024
                  description: A storage reference to an optional screenshot — never an inline image.
      responses:
        "201":
          description: The step was recorded (or the existing row returned on a retry).
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/AutomationRunStepReceipt" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ValidationError" }
        "429": { description: Throttled }

  /widget/page:
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Widget]
      operationId: reportWidgetPage
      summary: Page telemetry for the agents' context sidebar
      description: >
        ATH-135 — current page + honest browser-reported timezone and
        locale, merged into the visitor context; publishes ephemeral
        visitor.page_changed. IP geolocation (the map) is founder-gated
        on a geo provider.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url]
              properties:
                url: { type: string, maxLength: 500 }
                title: { type: [string, "null"], maxLength: 255 }
                timezone: { type: [string, "null"], maxLength: 64 }
                locale: { type: [string, "null"], maxLength: 16 }
                referrer: { type: [string, "null"], maxLength: 500 }
      responses:
        "204": { description: Recorded }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422": { $ref: "#/components/responses/ValidationError" }
        "429": { description: Throttled }

  /widget/broadcasting/auth:
    post:
      tags: [Realtime]
      operationId: authorizeVisitorChannel
      summary: Visitor channel grant (Pusher-protocol auth signature)
      description: >
        ATH-110 — the widget's counterpart to /broadcasting/auth
        (Doc 04 §3). Authenticated by the bearer visitor token. Grants
        exactly two channel shapes — private-visitor.{own id} and
        private-conversation.{id} the visitor participates in — and
        403s everything else (a visitor can never observe agent inbox
        or presence channels). The returned signature is what the
        client passes to Reverb to complete the subscription.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [channel_name, socket_id]
              properties:
                channel_name: { type: string, maxLength: 200 }
                socket_id: { type: string, maxLength: 50 }
      responses:
        "200":
          description: Channel granted
          content:
            application/json:
              schema:
                type: object
                required: [auth]
                properties:
                  auth: { type: string, description: "key:hmac-sha256(socket_id:channel_name)" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ValidationError" }
        "429": { description: Throttled }

  /workspaces/{workspaceId}/helpcenter/theme:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    get:
      tags: [KnowledgeBase]
      operationId: getHelpCenterTheme
      summary: Help-center theme (logo, header links, custom CSS, white-label)
      description: >
        Requires `settings.manage`. Scoped to the workspace in the path
        — a member of another workspace gets 403, never another tenant’s
        rows.
      responses:
        "200":
          description: Current theme (defaults when never configured)
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/HelpCenterTheme" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    put:
      tags: [KnowledgeBase]
      operationId: updateHelpCenterTheme
      summary: Update the help-center theme
      description: >
        ATH-094 — custom_css and white_label are Scale+ entitlements
        (feature.content.custom_css / feature.content.white_label):
        setting them on a lower plan is a 422. Colors and fonts come
        from brand profiles (ATH-083), not this endpoint.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/HelpCenterThemeInput" }
      responses:
        "200":
          description: Updated theme
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/HelpCenterTheme" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /workspaces/{workspaceId}/domains:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    get:
      tags: [KnowledgeBase]
      operationId: listDomains
      summary: Custom help-center domains for the workspace
      description: >
        ATH-093 — Doc 03 §5. Requires `settings.manage`. Each row carries
        the `cname_target` tenants must point their CNAME at; status moves
        pending → verified/failed via the async DNS check.
      responses:
        "200":
          description: Domains, newest first
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/CustomDomain" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [KnowledgeBase]
      operationId: addDomain
      summary: Attach a custom domain (starts CNAME verification)
      description: >
        ATH-093 — Doc 03 §5. Requires `settings.manage` and the
        `feature.content.custom_domain` entitlement (Scale+; 422 below
        it). The hostname must be a valid FQDN, not on an Cairn-owned
        zone, and globally unused. Verification is asynchronous: the row
        returns `pending` and flips once the CNAME check runs.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CustomDomainInput" }
      responses:
        "201":
          description: Domain registered, verification queued
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/CustomDomain" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /workspaces/{workspaceId}/domains/{domainId}:
    parameters:
      - { $ref: "#/components/parameters/WorkspaceId" }
      - name: domainId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    delete:
      tags: [KnowledgeBase]
      operationId: removeDomain
      summary: Detach a custom domain
      description: >
        ATH-093 — removing the row immediately stops host resolution and
        TLS-issuance authorization for the hostname.
      responses:
        "204": { description: Removed }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /workspaces/{workspaceId}/domains/{domainId}/verify:
    parameters:
      - { $ref: "#/components/parameters/WorkspaceId" }
      - name: domainId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [KnowledgeBase]
      operationId: verifyDomain
      summary: Re-run the CNAME verification check
      description: >
        ATH-093 — the doc's "verification check endpoint". Queues a fresh
        DNS check; poll listDomains for the outcome. Throttled.
      responses:
        "202":
          description: Verification queued
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/CustomDomain" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { description: Throttled }

  /workspaces/{workspaceId}/widget/identity-settings:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    get:
      tags: [Widget]
      operationId: getWidgetIdentitySettings
      summary: Widget identity HMAC secret (settings managers only)
      description: >
        Requires `settings.manage`. Scoped to the workspace in the path
        — a member of another workspace gets 403, never another tenant’s
        rows.
      responses:
        "200":
          description: Current identity configuration
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    required: [configured]
                    properties:
                      configured: { type: boolean }
                      secret: { type: [string, "null"] }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /workspaces/{workspaceId}/widget/identity-settings/rotate:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Widget]
      operationId: rotateWidgetIdentitySecret
      summary: Generate (or replace) the widget identity secret
      description: >
        ATH-118 — rotation invalidates every outstanding signature; the
        customer's backend must re-sign with the new secret.
      responses:
        "200":
          description: New secret
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    required: [secret]
                    properties:
                      secret: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /workspaces/{workspaceId}/helpcenter/auth-settings:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    get:
      tags: [KnowledgeBase]
      operationId: getHelpCenterAuthSettings
      summary: Help-center JWT handshake settings (shared secret)
      description: >
        ATH-096 — the customer's app signs visitor JWTs (HS256, exp
        required) with this per-workspace secret; the help center
        verifies them to unlock 'authenticated' articles. Readable by
        settings managers only.
      responses:
        "200":
          description: Current handshake configuration
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    required: [configured]
                    properties:
                      configured: { type: boolean }
                      secret: { type: [string, "null"] }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /workspaces/{workspaceId}/helpcenter/auth-settings/rotate:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [KnowledgeBase]
      operationId: rotateHelpCenterAuthSecret
      summary: Generate (or replace) the help-center JWT shared secret
      description: >
        ATH-096 — rotation invalidates every outstanding visitor JWT
        signed with the old secret immediately.
      responses:
        "200":
          description: New secret (show once, then fetch via settings)
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    required: [secret]
                    properties:
                      secret: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /guides/{guideId}/share:
    parameters:
      - name: guideId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    get:
      tags: [Guides]
      operationId: getGuideShare
      summary: Current share state + public URL
      description: >
        Requires `guides.view`. Scoped to the workspace in the path — a
        member of another workspace gets 403, never another tenant’s
        rows.
      responses:
        "200":
          description: Share state
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/GuideShare" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    put:
      tags: [Guides]
      operationId: updateGuideShare
      summary: Set the guide's share mode (private / unlisted / public)
      description: >
        ATH-097 — requires the content-publish permission (sharing IS
        publishing to the world). public_id is minted on first share
        and stable forever after; unlisted pages are never indexed,
        public pages honor the indexable toggle. Only published guides
        actually serve — a draft with a share mode still 404s.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [mode]
              properties:
                mode: { type: string, enum: [private, unlisted, public] }
                indexable: { type: boolean }
      responses:
        "200":
          description: Updated share state
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/GuideShare" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /guides/{guideId}/article:
    parameters:
      - name: guideId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [KnowledgeBase]
      operationId: createArticleFromGuide
      summary: One-click guide → linked KB article (the core Cairn glue)
      description: >
        ATH-090 — sections become headings, steps become numbered
        instructions with screenshots; the article stays linked to the
        guide and diverged_from_guide flips when the guide changes.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                collection_id: { type: [string, "null"] }
      responses:
        "201":
          description: Draft article created
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/Article" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /workspaces/{workspaceId}/exports:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    get:
      tags: [Exports]
      operationId: listExports
      summary: Download center — recent exports with status + signed URLs
      description: >
        Requires `guides.view`. Scoped to the workspace in the path — a
        member of another workspace gets 403, never another tenant’s
        rows.
      responses:
        "200":
          description: Newest first (50)
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Export" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Exports]
      operationId: createExport
      summary: Request an export (async — poll listExports until ready)
      description: >
        ATH-105 — queued render into S3; the requester is emailed when
        ready. Formats come from the exporter registry. ATH-106 adds
        pdf (headless Chromium render of the print stylesheet, brand
        kit applied); ATH-107 adds the zip bundles (html_zip:
        index.html + images/ as files; markdown_zip: guide.md +
        images/ with relative links); ATH-108 adds docx (PHPWord —
        title, numbered steps, embedded screenshots). ATH-244 adds
        video (server-side render of the step sequence to MP4; large,
        so it carries an `expires_at` retention window). `video` is
        offered only when feature.content.video_export is enabled for
        the workspace — requesting it otherwise is a 422, the same as
        an unknown format.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [subject_id, format]
              properties:
                subject_id: { $ref: "#/components/schemas/Ulid" }
                format: { type: string, enum: [markdown, html, pdf, html_zip, markdown_zip, docx, video] }
      responses:
        "202":
          description: Export queued
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/Export" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }
        "429": { description: Throttled }

  /workspaces/{workspaceId}/capture-policy:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    get:
      tags: [Captures]
      operationId: getCapturePolicy
      summary: Enforced capture rules — clients fetch at session start
      description: >
        Org-enforced redaction and domain rules (Doc 02 §3.1, §7, §8).
        Enforced entries cannot be overridden client-side. ATH-235 adds
        enforced_rules + policy_checksum (workspace redaction policy —
        Enterprise; empty/null below the plan).
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/CapturePolicy" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /workspaces/{workspaceId}/redaction-policy/rules:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    get:
      tags: [Captures]
      operationId: listRedactionPolicyRules
      summary: Workspace redaction policy rules
      description: >
        ATH-235 — Doc 02 §7.3–7.4. Requires `settings.manage`. Readable on
        any plan so a downgraded workspace can still audit (and delete)
        its rules; ingest and the capture-policy fetch stop applying them
        the moment the entitlement lapses.
      responses:
        "200":
          description: Rules, newest first
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/RedactionPolicyRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Captures]
      operationId: createRedactionPolicyRule
      summary: Add a redaction policy rule
      description: >
        ATH-235 — Doc 02 §7.3–7.4. Requires `settings.manage` and the
        Enterprise feature.security.enforced_redaction entitlement (422
        below it, ATH-094 pattern). New rules apply to captures ingested
        from this point on; existing guides are not re-scanned.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RedactionPolicyRuleInput" }
      responses:
        "201":
          description: Rule created
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/RedactionPolicyRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /workspaces/{workspaceId}/redaction-policy/rules/{ruleId}:
    parameters:
      - { $ref: "#/components/parameters/WorkspaceId" }
      - name: ruleId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    patch:
      tags: [Captures]
      operationId: updateRedactionPolicyRule
      summary: Update a redaction policy rule
      description: >
        ATH-235 — partial update; kind/pattern/category are re-validated
        together. Requires `settings.manage` + the Enterprise entitlement
        (422 below it). Already-applied regions keep their captured shape;
        the change affects future ingests.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                kind: { $ref: "#/components/schemas/RedactionRuleKind" }
                pattern: { type: [string, "null"], maxLength: 500 }
                category:
                  oneOf:
                    - $ref: "#/components/schemas/RedactionCategory"
                    - type: "null"
                enforced: { type: boolean }
      responses:
        "200":
          description: Updated rule
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/RedactionPolicyRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }
    delete:
      tags: [Captures]
      operationId: deleteRedactionPolicyRule
      summary: Remove a redaction policy rule
      description: >
        ATH-235 — allowed on any plan (cleanup after downgrade). Regions
        the rule already burned into guides remain: policy redactions are
        evidence, not reversible state.
      responses:
        "204": { description: Removed }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /workspaces/{workspaceId}/audiences:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    get:
      tags: [KnowledgeBase]
      operationId: listAudiences
      summary: The workspace's audiences (trio + custom)
      description: >
        PA-231 — Doc 07 §5. Requires `helpcenter.manage` and the
        `feature.audiences.variants` flag; below the flag every route in
        this group is 404. Returns the fixed trio (public, customers,
        internal) plus any Enterprise custom audiences, system rows first.
      responses:
        "200":
          description: Audiences, system trio first
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Audience" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [KnowledgeBase]
      operationId: createAudience
      summary: Add a custom audience
      description: >
        PA-231 — Doc 07 §5. Requires `helpcenter.manage`, the
        `feature.audiences.variants` flag, and the Enterprise
        `feature.audiences.custom` entitlement (422 below it, ATH-094
        pattern). `key` may not collide with the reserved trio.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/AudienceInput" }
      responses:
        "201":
          description: Audience created
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/Audience" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /workspaces/{workspaceId}/audiences/{audienceId}:
    parameters:
      - { $ref: "#/components/parameters/WorkspaceId" }
      - name: audienceId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    patch:
      tags: [KnowledgeBase]
      operationId: updateAudience
      summary: Rename or re-scope a custom audience
      description: >
        PA-231 — partial update of a CUSTOM audience's name and base
        retrieval scope. System trio rows are immutable (422). Requires
        `helpcenter.manage` + the flag.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string, minLength: 1, maxLength: 80 }
                retrieval_scope: { type: string, enum: [public, customers, internal] }
      responses:
        "200":
          description: Updated audience
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/Audience" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }
    delete:
      tags: [KnowledgeBase]
      operationId: deleteAudience
      summary: Remove a custom audience
      description: >
        PA-231 — deletes a CUSTOM audience; its publications cascade, so
        content is unpublished from it and that audience's chunks/media
        variants clear. System trio rows cannot be deleted (422). Requires
        `helpcenter.manage` + the flag.
      responses:
        "204": { description: Removed }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /workspaces/{workspaceId}/audience-publications/{contentType}/{contentId}:
    parameters:
      - { $ref: "#/components/parameters/WorkspaceId" }
      - name: contentType
        in: path
        required: true
        schema: { type: string, enum: [guide, article] }
      - name: contentId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    get:
      tags: [KnowledgeBase]
      operationId: listContentAudiencePublications
      summary: A content item's audience publications (a governance-matrix row)
      description: >
        PA-231 — Doc 07 §5. The per-audience variant matrix for one guide
        or article: which audiences it is published to, each variant's
        redaction profile and freshness SLA. Requires `helpcenter.manage`
        + the flag; an unknown content type is 404.
      responses:
        "200":
          description: Publications for this content
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/ContentAudiencePublication" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /workspaces/{workspaceId}/audience-publications/{contentType}/{contentId}/{audienceId}:
    parameters:
      - { $ref: "#/components/parameters/WorkspaceId" }
      - name: contentType
        in: path
        required: true
        schema: { type: string, enum: [guide, article] }
      - name: contentId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
      - name: audienceId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    put:
      tags: [KnowledgeBase]
      operationId: setContentAudiencePublication
      summary: Publish content to an audience with a per-audience variant
      description: >
        PA-231 — Doc 07 §5. Idempotent by (content, audience): sets this
        audience's redaction profile (the Doc 02 §7 categories its media
        variant auto-blurs at render), per-audience `verify_interval_days`,
        and publish state, then re-indexes the audience's chunks and renders
        its blurred media variant. Requires `helpcenter.manage` + the flag.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ContentAudiencePublicationInput" }
      responses:
        "200":
          description: Publication updated
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/ContentAudiencePublication" }
        "201":
          description: Publication created
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/ContentAudiencePublication" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }
    delete:
      tags: [KnowledgeBase]
      operationId: deleteContentAudiencePublication
      summary: Unpublish content from an audience
      description: >
        PA-231 — removes the publication and clears that audience's chunks
        and media variant. Requires `helpcenter.manage` + the flag.
      responses:
        "204": { description: Removed }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /workspaces/{workspaceId}/governance/matrix:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    get:
      tags: [KnowledgeBase]
      operationId: getGovernanceMatrix
      summary: The content × audience governance matrix
      description: >
        PA-232 (ATH-315) — Doc 07 §5 governance console. One row per content
        item that carries any audience publication, one cell per audience:
        published?, redaction profile, translation freshness, verification
        status (computed against the per-audience `verify_interval_days`
        override so a shorter per-audience SLA can read `due` while the
        content-level schedule reads `verified`). Assembled in PHP — no JSON
        SQL predicates. Requires `helpcenter.manage` and the
        `feature.audiences.governance` flag; below the flag every route in
        this group is 404. Page-paginated (fetch `limit`+1 to detect the
        next page) because a governed corpus is bounded per workspace.
      parameters:
        - name: content_type
          in: query
          schema: { type: string, enum: [guide, article] }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
        - name: page
          in: query
          schema: { type: integer, minimum: 1, default: 1 }
      responses:
        "200":
          description: A page of matrix rows plus the audience columns
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/GovernanceMatrixRow" }
                  meta:
                    type: object
                    required: [audiences, page, per_page, has_more]
                    properties:
                      audiences:
                        type: array
                        description: The column order — system trio first, then custom.
                        items: { $ref: "#/components/schemas/GovernanceMatrixAudience" }
                      page: { type: integer }
                      per_page: { type: integer }
                      has_more: { type: boolean }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /workspaces/{workspaceId}/governance/bulk-publish:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [KnowledgeBase]
      operationId: bulkPublishAudiences
      summary: Publish or unpublish many (content × audience) pairs at once
      description: >
        PA-232 (ATH-315) — Doc 07 §5 bulk operations. Each operation is
        applied through the SAME per-audience publish path as the single
        PA-231 endpoint (SyncAudiencePublications reconciles the corpus), so
        the bulk op can never leak a variant the single write would not.
        PARTIAL SUCCESS with per-item results: one bad item (foreign id, or a
        `public`-scope publish refused by the Enterprise policy gate) fails
        that item alone and the rest still apply — the response reports each
        item's outcome. Idempotent: re-sending the same operation leaves the
        same state. Requires `helpcenter.manage` + the flag.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/BulkAudiencePublishInput" }
      responses:
        "200":
          description: Per-item results (the call itself succeeds even with failed items)
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/BulkAudiencePublishResult" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /workspaces/{workspaceId}/governance/audit:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    get:
      tags: [KnowledgeBase]
      operationId: listGovernanceAudit
      summary: The audit trail of audience publish/unpublish/variant changes
      description: >
        PA-232 (ATH-315) — Doc 07 §5 "audit trail of audience changes". Reads
        the append-only `audit_logs` (ATH-013) filtered to this workspace's
        `audience.*` actions, newest first. Requires `helpcenter.manage` + the
        flag.
      parameters:
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
        - { $ref: "#/components/parameters/Cursor" }
      responses:
        "200":
          description: A page of audit entries
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/GovernanceAuditEntry" }
                  meta:
                    type: object
                    required: [next_cursor]
                    properties:
                      next_cursor: { type: [string, "null"] }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /workspaces/{workspaceId}/governance/public-approval/{contentType}/{contentId}:
    parameters:
      - { $ref: "#/components/parameters/WorkspaceId" }
      - name: contentType
        in: path
        required: true
        schema: { type: string, enum: [guide, article] }
      - name: contentId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    get:
      tags: [KnowledgeBase]
      operationId: getPublicationApproval
      summary: The public-publish clearance for one content item
      description: >
        PA-232 (ATH-315) — Doc 07 §5 Enterprise policy. Reports whether this
        content has the two things a `public`-scope publish requires under the
        Enterprise `feature.audiences.public_policy`: a recorded redaction
        scan pass AND a content_manager approval. `data` is null when no
        clearance has been recorded. Requires `helpcenter.manage` + the flag.
      responses:
        "200":
          description: The clearance record, or null if none exists yet
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    oneOf:
                      - $ref: "#/components/schemas/PublicationApproval"
                      - type: "null"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    put:
      tags: [KnowledgeBase]
      operationId: setPublicationApproval
      summary: Record (or withdraw) the public-publish clearance
      description: >
        PA-232 (ATH-315) — Doc 07 §5 Enterprise policy. Idempotent by content.
        `approved:true` records the acting content_manager (the route's
        `helpcenter.manage` gate IS the content_manager-tier check) as the
        approver; `approved:false` withdraws it. `redaction_scan_passed`
        records the scan signal — set here manually until PA-233 automates the
        leak scan that will own this field. Clearance requires BOTH.
        Requires `helpcenter.manage` + the flag.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PublicationApprovalInput" }
      responses:
        "200":
          description: The updated clearance
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/PublicationApproval" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /invitations/preview:
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Invitations]
      operationId: previewInvitation
      summary: Preview a pending invitation by its emailed token
      description: |
        Public — the token IS the credential. The join screen receives it
        from the emailed action URL, removes it from browser history after
        hydration, and posts it in this request body. This API endpoint never
        accepts the token in its URL. Lets the join screen show workspace +
        role before the invitee registers or logs in.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [token]
              properties:
                token: { type: string }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/InvitationPreview" }
        "404": { $ref: "#/components/responses/NotFound" }
        "410":
          description: Invitation expired or already accepted
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /invitations/accept:
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Invitations]
      operationId: acceptInvitation
      summary: Accept an invitation and join its workspace
      description: |
        Caller must be authenticated and their email must match the invitee
        email. Idempotent per token — replays after success return 410.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [token]
              properties:
                token: { type: string }
      responses:
        "201":
          description: Joined
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/Member" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: Authenticated user's email does not match the invitee
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404": { $ref: "#/components/responses/NotFound" }
        "410":
          description: Invitation expired or already accepted
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "422": { $ref: "#/components/responses/ValidationError" }

  # ── API keys ────────────────────────────────────────────────────────────
  # Managing keys is deliberately NOT part of the public API: a token
  # that can mint tokens can mint a `*` token and escape its own limits
  # (ATH-250, ApiAbilities::grants() maps api_keys.manage to null).
  /workspaces/{workspaceId}/api-keys:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    get:
      tags: [ApiKeys]
      operationId: listApiKeys
      summary: List the workspace's API keys (never the secret)
      description: >
        ATH-250 — requires `api_keys.manage`. Returns metadata only:
        the plaintext key existed once, in the create response, and is
        not recoverable from anywhere including here. `key_prefix` is
        what lets a human match a row to the key in their password
        manager. Revoked keys are included, because "which keys did we
        kill and when" is the question this list is most often opened
        to answer.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/ApiKey" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [ApiKeys]
      operationId: createApiKey
      description: >
        Requires `api_keys.manage`. Scoped to the workspace in the path
        — a member of another workspace gets 403, never another tenant’s
        rows.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, abilities]
              properties:
                name: { type: string }
                abilities:
                  type: array
                  items: { type: string }
                  description: >
                    Abilities from the ATH-250 vocabulary
                    (`ApiAbilities::all()`), e.g.
                    ["contacts:read","conversations:read"]. Unknown
                    abilities are rejected with 422 rather than stored
                    — a key holding a typo would silently grant nothing
                    and fail at call time instead of at issue time. The
                    vocabulary is derived from the RBAC permission
                    catalog, so it cannot name a capability the roles
                    model does not have; the two exceptions are the
                    retrieval surface's `retrieval:query` and
                    `retrieval:internal` (ATH-258), which gate a
                    surface with no catalog analogue. `*` grants
                    everything the public API exposes.
                expires_at: { type: string, format: date-time }
      responses:
        "201":
          description: Created — plaintext key returned ONCE
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/ApiKey" }
                  plaintext_key: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /workspaces/{workspaceId}/api-keys/{apiKeyId}:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - name: apiKeyId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    delete:
      tags: [ApiKeys]
      operationId: revokeApiKey
      summary: Revoke a key — takes effect on the next request
      description: >
        ATH-250 — requires `api_keys.manage`. Sets `revoked_at`; the
        row is kept rather than deleted so an incident review can still
        read `last_used_at` and answer "was it used before we killed
        it". Idempotent: revoking an already-revoked key returns the
        same 200 with the original timestamp, because the caller's
        intent ("this key must not work") is already satisfied and a
        409 would only encourage retry loops during the one situation
        where speed matters.
      responses:
        "200":
          description: Revoked
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/ApiKey" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ── OAuth app platform (ATH-252, Doc 06 §4) ─────────────────────────────
  /oauth/token:
    post:
      tags: [OAuthApps]
      operationId: exchangeOAuthToken
      summary: Authorization server — exchange a code, or rotate a refresh token
      security: []
      description: >
        ATH-252 — the ONLY path in this spec that carries no ability and
        no bearer token, because it is what mints them. It authenticates
        the CLIENT (`client_id` + `client_secret`) and, for the
        authorization-code grant, the PKCE `code_verifier`. An Cairn API
        key presented here is ignored and the request still fails: this
        endpoint answers nothing to a credential it did not issue.


        PKCE is MANDATORY, not negotiable. `code_challenge_method` must
        be `S256` at the authorize step and a matching `code_verifier`
        must arrive here; there is no `plain` method and no path that
        skips the check, so a downgrade attempt is a 400 rather than a
        weaker flow.


        An authorization code is SINGLE USE and short-lived. Replaying a
        consumed code fails AND revokes the access token that code
        already issued, because a replay means the code leaked and the
        first exchange may not have been the legitimate one.


        Refresh tokens ROTATE: every refresh returns a new refresh token
        and consumes the presented one. Presenting an already-consumed
        refresh token is treated as theft and revokes the app's entire
        token family for that workspace — the standard OAuth 2.1 /
        RFC 6819 §5.2.2.3 response, and the reason rotation is worth
        having at all.


        Errors follow RFC 6749 §5.2 (`error` + `error_description`)
        rather than the house `error.code` envelope. That is a deliberate
        exception: every off-the-shelf OAuth client library parses this
        shape, and an authorization server that invents its own error
        format is one that integrators have to hand-write a client for.
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema: { $ref: "#/components/schemas/OAuthTokenRequest" }
          application/json:
            schema: { $ref: "#/components/schemas/OAuthTokenRequest" }
      responses:
        "200":
          description: Token issued
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OAuthTokenResponse" }
        "400":
          description: RFC 6749 §5.2 error — bad grant, PKCE failure, or replay
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OAuthError" }
        "401":
          description: Client authentication failed
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OAuthError" }

  /oauth/introspect:
    post:
      tags: [OAuthApps]
      operationId: introspectOAuthToken
      summary: RFC 7662 token introspection — the connection test for an iPaaS client
      security: []
      description: >
        ATH-253 — Zapier and n8n both need a "is this connection still
        good?" call they can make on a schedule. ATH-250 deferred a
        `GET /v1/token` for that, because an ability-free endpoint that
        answers 2xx to a bearer token is precisely what its
        route-enumeration invariant exists to forbid, and excluding one
        would have made the exclusion list the real boundary.


        This endpoint is not that. It is RFC 7662 introspection, and the
        RFC's own design is what resolves the tension: the caller
        authenticates as the CLIENT (`client_id` + `client_secret`) and
        passes the token it is asking about as a PARAMETER. A bearer
        token is not a credential here, exactly as at `/oauth/token`. So
        this route is swept by PublicApiSurfaceTest like every other
        `api/v1` route, answers 401 to the live wildcard API key the
        sweep fires at it, and needs no exclusion and no ability. The
        invariant is untouched because nothing ability-free was added to
        it.


        Per RFC 7662 §2.2 an inactive, expired, revoked or unknown token
        is `{"active": false}` with status 200 — NOT an error. A client
        must branch on `active`, never on the status code. Nothing
        beyond `active` is returned in that case, so this endpoint
        cannot be used to probe which token strings exist.


        Scoping: a client may only introspect a token it was itself
        issued. Asking about another app's token answers
        `{"active": false}` — the same answer as a token that does not
        exist, because distinguishing the two would turn this into an
        oracle for other apps' credentials.
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema: { $ref: "#/components/schemas/OAuthIntrospectionRequest" }
          application/json:
            schema: { $ref: "#/components/schemas/OAuthIntrospectionRequest" }
      responses:
        "200":
          description: >
            Introspection result. `active: false` is a successful
            response describing an unusable token, not a failure.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OAuthIntrospection" }
        "401":
          description: Client authentication failed
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OAuthError" }

  /workspaces/{workspaceId}/oauth-apps:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    get:
      tags: [OAuthApps]
      operationId: listOAuthApps
      summary: Apps this workspace has REGISTERED as a developer
      description: >
        ATH-252 — requires `integrations.manage`. These are the apps the
        workspace OWNS and publishes, not the apps installed into it
        (that is `/oauth-installs`). The client secret existed once, in
        the create response, and is not recoverable from here or from
        anywhere else.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/OAuthApp" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [OAuthApps]
      operationId: registerOAuthApp
      summary: Register an app — client secret returned ONCE
      description: >
        ATH-252 — requires `integrations.manage`. `redirect_uris` are
        matched EXACTLY at authorize time: no prefix matching, no
        wildcards, no trailing-slash forgiveness. A near miss is a
        refusal, because prefix matching on a redirect URI is how an
        open redirect turns into a stolen authorization code.


        `requested_abilities` is the app's CEILING, drawn from the
        ATH-250 vocabulary (`ApiAbilities::all()`); unknown abilities are
        rejected 422 at registration rather than silently granting
        nothing later. No consent may exceed this set, and no consent may
        exceed the granting user's own permissions either.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, redirect_uris, requested_abilities]
              properties:
                name: { type: string, maxLength: 120 }
                description: { type: string, maxLength: 500, nullable: true }
                redirect_uris:
                  type: array
                  minItems: 1
                  items: { type: string, format: uri }
                  description: >
                    Absolute https URIs (http is accepted only for
                    localhost, for local development). Matched byte for
                    byte at authorize time.
                requested_abilities:
                  type: array
                  minItems: 1
                  items: { type: string }
      responses:
        "201":
          description: Registered — client secret returned ONCE
          content:
            application/json:
              schema:
                type: object
                required: [data, client_secret]
                properties:
                  data: { $ref: "#/components/schemas/OAuthApp" }
                  client_secret: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /workspaces/{workspaceId}/oauth-apps/{appId}:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - name: appId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    delete:
      tags: [OAuthApps]
      operationId: deleteOAuthApp
      summary: Withdraw an app — kills every install and token it holds
      description: >
        ATH-252 — requires `integrations.manage`, and only the OWNING
        workspace may call it. Withdrawing an app revokes every install
        of it in every workspace and every token those installs hold,
        immediately: a developer shutting down an integration should not
        leave live credentials pointed at their customers.
      responses:
        "204": { description: Withdrawn }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /workspaces/{workspaceId}/oauth-installs:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    get:
      tags: [OAuthApps]
      operationId: listOAuthInstalls
      summary: Third-party apps installed INTO this workspace
      description: >
        ATH-252 — requires `integrations.manage`. The other side of
        `/oauth-apps`: what this workspace has granted to somebody else's
        app, in the ability vocabulary the consent screen used, so
        "what can this integration actually do" has an answer that does
        not require reading code.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/OAuthAppInstall" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /workspaces/{workspaceId}/oauth-installs/{installId}:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - name: installId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    delete:
      tags: [OAuthApps]
      operationId: revokeOAuthInstall
      summary: Revoke an install — its tokens die on the next request
      description: >
        ATH-252 — requires `integrations.manage`. Sets `revoked_at` on
        the install AND on every access token it issued, so access ends
        immediately rather than at the next token expiry. The rows are
        kept rather than deleted for the same reason a revoked API key's
        row is kept: `last_used_at` is what an incident review reads.


        Idempotent — revoking an already-revoked install returns the same
        200 with the original timestamp, because the caller's intent is
        already satisfied and a 409 would only encourage retry loops in
        the one situation where speed matters.
      responses:
        "200":
          description: Revoked
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/OAuthAppInstall" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ── Super-admin (Layer 1, Doc 01 §5) ────────────────────────────────────
  # All /admin endpoints require users.is_super_admin; every mutation is
  # audit-logged with actor_type=super_admin.
  /admin/ai-margin:
    get:
      tags: [Admin]
      operationId: getAiMargin
      summary: AI margin dashboard — cost vs revenue (platform.admin)
      description: >
        ATH-102 — provider cost (llm_calls.cost_micros) vs credit
        revenue (agent debits), globally + by task + top workspaces.
      responses:
        "200":
          description: Margin report
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    properties:
                      cost_micros: { type: integer }
                      revenue_micros: { type: integer }
                      margin_micros: { type: integer }
                      margin_pct: { type: [number, "null"] }
                      calls: { type: integer }
                      by_task:
                        type: array
                        items:
                          type: object
                          properties:
                            task: { type: string }
                            calls: { type: integer }
                            cost_micros: { type: integer }
                            tokens: { type: integer }
                      top_workspaces:
                        type: array
                        items:
                          type: object
                          properties:
                            workspace_id: { $ref: "#/components/schemas/Ulid" }
                            cost_micros: { type: integer }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /admin/workspaces:
    get:
      tags: [Admin]
      operationId: adminSearchWorkspaces
      summary: Search tenants across the platform
      description: Matches workspace name/slug substrings or an exact ULID. Includes suspended and soft-deleted tenants.
      parameters:
        - name: q
          in: query
          schema: { type: string }
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/PaginatedList"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/AdminWorkspace" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /admin/workspaces/{workspaceId}:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    patch:
      tags: [Admin]
      operationId: adminUpdateWorkspace
      summary: Override a tenant's plan
      description: Sets plan_id directly (comps, sales overrides). Purges the tenant's plan-derived feature-flag cache — ad-hoc flag targeting on those flags must be re-applied afterwards.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [plan]
              properties:
                plan: { type: string, enum: [free, starter, growth, scale, enterprise] }
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/AdminWorkspace" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }
    delete:
      tags: [Admin]
      operationId: adminDeleteWorkspace
      summary: Soft-delete a tenant
      description: Members lose access immediately (context resolution 404s); data is retained for recovery.
      responses:
        "204": { description: Deleted }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /admin/workspaces/{workspaceId}/suspension:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    put:
      tags: [Admin]
      operationId: adminSuspendWorkspace
      summary: Suspend a tenant (abuse/spam control)
      description: Members are blocked (403) at workspace-context resolution until unsuspended.
      responses:
        "200":
          description: Suspended
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/AdminWorkspace" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Admin]
      operationId: adminUnsuspendWorkspace
      summary: Lift a tenant suspension
      description: >
        Super-admin only (`platform.admin`); a workspace role can never
        grant it.
      responses:
        "200":
          description: Unsuspended
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/AdminWorkspace" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /admin/workspaces/{workspaceId}/credits:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    post:
      tags: [Admin]
      operationId: adminGrantWorkspaceCredits
      summary: Grant AI credits to a tenant
      description: >
        Adds a positive, append-only credit-ledger entry. The required
        Idempotency-Key is also used as the ledger reference, so retries
        cannot grant credits twice even after the HTTP replay cache expires.
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [amount_cents]
              properties:
                amount_cents: { type: integer, minimum: 1, maximum: 100000000 }
                note: { type: [string, "null"], maxLength: 500 }
      responses:
        "200":
          description: Grant applied or previously applied
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    required: [applied, balance_cents, reference]
                    properties:
                      applied: { type: boolean }
                      balance_cents: { type: integer }
                      reference: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }
  /admin/workspaces/{workspaceId}/features/{feature}:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - name: feature
        in: path
        required: true
        schema: { type: string }
        description: A defined Pennant flag name (feature.<area>.<name>)
    put:
      tags: [Admin]
      operationId: adminSetFeatureOverride
      summary: Force a feature flag on/off for one tenant
      description: >
        Super-admin only (`platform.admin`); a workspace role can never
        grant it.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [active]
              properties:
                active: { type: boolean }
      responses:
        "200":
          description: Override stored
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    required: [feature, active]
                    properties:
                      feature: { type: string }
                      active: { type: boolean }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }
    delete:
      tags: [Admin]
      operationId: adminClearFeatureOverride
      summary: Clear an override (revert to plan-derived resolution)
      description: >
        Super-admin only (`platform.admin`); a workspace role can never
        grant it.
      responses:
        "204": { description: Override cleared }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /admin/users/{userId}/impersonation:
    parameters:
      - name: userId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Admin]
      operationId: adminStartImpersonation
      summary: Start impersonating a user
      description: |
        Issues a short-lived token that authenticates AS the target user
        (ATH-034). Starting a new impersonation revokes any previous one for
        the same user. Actions taken with the token are audit-logged as
        actor_type=super_admin with the target recorded in metadata. Super
        admins cannot be impersonated.
      responses:
        "201":
          description: Impersonation token issued — shown once
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    required: [token, expires_at, user]
                    properties:
                      token: { type: string }
                      expires_at: { type: string, format: date-time }
                      user: { $ref: "#/components/schemas/User" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }
    delete:
      tags: [Admin]
      operationId: adminStopImpersonation
      summary: Revoke a user's impersonation session immediately
      description: >
        Super-admin only (`platform.admin`); a workspace role can never
        grant it.
      responses:
        "204": { description: Revoked (idempotent) }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ATH-004 — the old "Billing (read slice)" stub that used to live here
  # (/workspaces/{id}/billing/credits) is gone. It described an endpoint
  # NOTHING implements, and it reused operationId `getCreditBalance`,
  # which is invalid OpenAPI: two operations collapse into one generated
  # type and whichever the generator sees last wins. The real, routed
  # endpoint is /workspaces/{workspaceId}/credits (ATH-044).

  # ── Strict screenshot originals (PA-279, Doc 02 §7.1) ──────────────────
  /workspaces/{workspaceId}/media/{mediaId}/original:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - name: mediaId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    delete:
      tags: [Media]
      operationId: deleteMediaOriginal
      summary: Permanently delete a screenshot original in strict mode
      description: |
        PA-279 (Doc 02 §7.1) — requires `settings.security`, a workspace
        with `settings.capture.strict_originals = true`, and a screenshot
        that already has an applied redaction rendered into its `edited`
        derivative. The operation permanently deletes the encrypted raw
        object and every unredacted derivative (annotated + thumbnails),
        retaining only the redacted edited image. It is intentionally
        irreversible: future crop/blur or audience-profile changes cannot
        be re-rendered after deletion.

        Idempotent: deleting an already-deleted original returns the same
        state and original timestamp. A foreign-tenant media id is 404.
        404 below the default-off `feature.capture.strict_originals` flag
        unless this workspace already enabled strict mode (a rollout
        rollback never strands an existing privacy control). 409 means no
        applied redacted derivative exists yet; apply a blur and wait for
        rendering before retrying. 422 means the media is not a screenshot
        or strict mode is not enabled for this workspace.
      responses:
        "200":
          description: Original absent; repeated calls return the same state
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/MediaOriginalState" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ValidationError" }

  # ── Captures (doc 02 contract) ──────────────────────────────────────────
  /captures:
    post:
      tags: [Captures]
      operationId: startCaptureSession
      description: >
        Requires `guides.create`. Scoped to the workspace in the path —
        a member of another workspace gets 403, never another tenant’s
        rows.
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [source]
              properties:
                source: { type: string, enum: [extension, desktop, upload] }
                app_context: { $ref: "#/components/schemas/AppContext" }
                capture_request_id:
                  description: >
                    ATH-282 (Doc 07 §1 disposition "b") — the capture request
                    this recording answers, when the session was opened from a
                    capture-request deep link. Must reference an open or
                    in-progress request in the SAME workspace; anything else is
                    422, which is what stops a deep link from tagging a
                    recording onto another tenant's work order. Accepting it
                    moves that request to `in_progress`, and the guide assembled
                    at finalize is linked back to it — closing the loop to the
                    gap cluster.
                  oneOf:
                    - { $ref: "#/components/schemas/Ulid" }
                    - type: "null"
      responses:
        "201":
          description: Session started
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/CaptureSession" }

  /captures/{captureId}/steps:
    parameters: [{ $ref: "#/components/parameters/CaptureId" }]
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Captures]
      operationId: appendCaptureSteps
      summary: Batch-append steps (idempotent by (session, seq))
      description: >
        Requires `guides.create`. Scoped to the workspace in the path —
        a member of another workspace gets 403, never another tenant’s
        rows.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [steps]
              properties:
                steps:
                  type: array
                  maxItems: 50
                  items: { $ref: "#/components/schemas/CaptureStep" }
      responses:
        "202": { description: Accepted }
        "409":
          description: Session already finalized
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /captures/{captureId}/media:
    parameters: [{ $ref: "#/components/parameters/CaptureId" }]
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Media]
      operationId: uploadCaptureMedia
      summary: Upload a screenshot or audio asset for a capture session
      description: |
        Multipart upload, retry/resume-safe via content addressing: send the
        client-computed sha256 and a re-upload of the same bytes returns the
        existing asset instead of storing a duplicate. Chunked/tus-style
        resumable transfer is deferred until the desktop app needs it
        (Doc 08 §7 open decisions) — extension screenshots are small.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [kind, file]
              properties:
                kind: { type: string, enum: [screenshot, audio] }
                file: { type: string, format: binary }
                sha256:
                  type: string
                  description: Client-computed content hash (hex); enables idempotent retries
      responses:
        "201":
          description: Stored (or already present — same body either way)
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/MediaAsset" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: Session already finalized
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /captures/{captureId}/finalize:
    parameters: [{ $ref: "#/components/parameters/CaptureId" }]
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Captures]
      operationId: finalizeCaptureSession
      summary: Finalize session and assemble the guide (idempotent)
      description: >
        ATH-047 — a workspace at its plan's guide ceiling gets 402
        `plan_limit_reached` here, at the API boundary, rather than
        losing the capture inside the assembly job. Re-finalizing a
        session whose guide already exists always succeeds: the limit
        blocks new guides, and a replay creates none.
      responses:
        "201":
          description: Guide assembly started
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    required: [guide_id, status]
                    properties:
                      guide_id: { $ref: "#/components/schemas/Ulid" }
                      status: { type: string, enum: [assembling, ready] }
        "402":
          description: Plan guide limit reached (plan_limit_reached)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /captures/{captureId}/mobile-upload-link:
    parameters: [{ $ref: "#/components/parameters/CaptureId" }]
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Captures]
      operationId: createCaptureMobileUploadLink
      summary: Mint a short-lived signed "add from phone" link for a capture session
      description: >
        PA-184 (Doc 02 §5) — an authoring member mints a short-lived,
        signed link a phone opens to add a screenshot to THIS capture
        session. Requires `guides.create`, scoped to the header-resolved
        workspace: a session in another workspace is 404, never another
        tenant's rows. The link is a Laravel signed URL (HMAC over the
        session id + expiry), so no token is stored and the id is useless
        without the matching signature — encode `url` as a QR for the
        phone. 404 below `feature.capture.mobile_upload`; 422 once the
        session is no longer recording.
      responses:
        "201":
          description: Link minted
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/MobileUploadLink" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /captures/{captureId}/mobile-upload:
    parameters: [{ $ref: "#/components/parameters/CaptureId" }]
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Media]
      operationId: uploadCaptureMobileMedia
      summary: Upload a screenshot from a phone via a signed mobile-upload link
      description: |
        PA-184 (Doc 02 §5) — the phone's upload endpoint. NOT member-authed:
        the signed URL minted by createCaptureMobileUploadLink IS the
        credential (`security: []`). Laravel's `signed` middleware validates
        the HMAC + expiry before the controller runs, so a tampered id, an
        expired link, or a signature minted for another session/tenant is
        refused (403) — an upload can only ever land in the token's own
        capture session and workspace. The image flows through the SAME
        content-addressed media pipeline as uploadCaptureMedia (client
        sha256 → dedup) and is attached to the session as a screenshot
        step. Only images are accepted, mime + size enforced (422). 404
        below `feature.capture.mobile_upload`; 409 once the session is no
        longer recording.
      security: []
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file: { type: string, format: binary }
                sha256:
                  type: string
                  description: Client-computed content hash (hex); enables idempotent retries
      responses:
        "201":
          description: Stored (or already present — same body either way)
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/MediaAsset" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: Session no longer recording
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "422": { $ref: "#/components/responses/ValidationError" }

  # ── Guides (read slice; editor ops extend in doc 03 PRs) ────────────────
  /guides:
    get:
      tags: [Guides]
      operationId: listGuides
      description: >
        Requires `guides.view`. Scoped to the workspace in the path — a
        member of another workspace gets 403, never another tenant’s
        rows.
      parameters:
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
        - name: status
          in: query
          schema: { type: string, enum: [draft, in_review, published, archived] }
        - name: folder_id
          in: query
          schema: { $ref: "#/components/schemas/Ulid" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/PaginatedList"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/GuideSummary" }

  /guides/{guideId}:
    parameters:
      - name: guideId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    get:
      tags: [Guides]
      operationId: getGuide
      description: >
        Requires `guides.view`. Scoped to the workspace in the path — a
        member of another workspace gets 403, never another tenant’s
        rows.
      responses:
        "200":
          description: Full guide with steps and signed media URLs
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Guide" }
        "404": { $ref: "#/components/responses/NotFound" }

  /guides/{guideId}/ops:
    parameters:
      - name: guideId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Guides]
      operationId: applyGuideOps
      summary: Apply editor operations (autosave batch)
      description: >
        ATH-080 — typed editor operations applied in order and appended to the
        guide's operation log (undo + version snapshots, Doc 03 §2). The op
        vocabulary grows with the editor tickets (ATH-081+).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ops]
              properties:
                ops:
                  type: array
                  minItems: 1
                  maxItems: 50
                  items: { $ref: "#/components/schemas/GuideOp" }
      responses:
        "200":
          description: Ops applied
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    required: [applied, last_seq]
                    properties:
                      applied: { type: integer, minimum: 0 }
                      last_seq: { type: integer, minimum: 0, description: Op-log sequence after this batch }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /media/{mediaId}/redactions:
    parameters:
      - name: mediaId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Guides]
      operationId: decideMediaRedactions
      summary: Accept or dismiss Smart Blur suggestions in bulk
      description: >
        PA-277 — records a durable author decision for assisted Smart Blur
        regions on one screenshot. Accepted regions are applied to the
        derived image; dismissed regions stay excluded. Decisions survive
        detector re-runs. Requires `guides.edit_any` and
        `feature.capture.smart_blur`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [region_ids, decision]
              properties:
                region_ids:
                  type: array
                  minItems: 1
                  maxItems: 100
                  uniqueItems: true
                  items: { $ref: "#/components/schemas/Ulid" }
                decision:
                  type: string
                  enum: [accept, dismiss]
      responses:
        "200":
          description: Decision recorded and screenshot render queued
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    required: [updated, decision, regions]
                    properties:
                      updated: { type: integer, minimum: 0 }
                      decision: { type: string, enum: [accept, dismiss] }
                      regions:
                        type: array
                        items: { $ref: "#/components/schemas/SmartBlurSuggestion" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /guides/{guideId}/assist:
    parameters:
      - name: guideId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Guides]
      operationId: assistGuide
      summary: AI assist — returns a suggestion; applying it goes through ops
      description: >
        ATH-085 — rewrite step / adjust tone / regenerate title+description /
        summarize (Doc 03 §2). Nothing persists here: the editor shows the
        suggestion and applies it via applyGuideOps, so every AI-assisted
        change is a logged, undoable operation.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [action]
              properties:
                action:
                  type: string
                  enum: [rewrite_step, adjust_tone, regenerate_meta, summarize]
                step_id: { type: string, description: Required for rewrite_step / adjust_tone }
                tone:
                  type: string
                  enum: [friendly, formal, concise, enthusiastic]
                  description: Required for adjust_tone
      responses:
        "200":
          description: Suggestion
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    required: [suggestion]
                    properties:
                      suggestion:
                        type: object
                        description: "rewrite_step/adjust_tone: {text} · regenerate_meta: {title, description} · summarize: {summary}"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }
        "429": { description: Throttled }

  /guides/{guideId}/versions:
    parameters:
      - name: guideId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    get:
      tags: [Guides]
      operationId: listGuideVersions
      summary: Version history within the plan's retention window
      description: >
        ATH-086 — rows are never deleted; retention (Doc 03 §2.1) is a
        query-time window per plan, so an upgrade restores older history.
      responses:
        "200":
          description: Versions, newest first
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      required: [number, created_at, step_count]
                      properties:
                        number: { type: integer }
                        label: { type: [string, "null"] }
                        created_by: { type: [string, "null"] }
                        created_at: { type: string, format: date-time }
                        step_count: { type: integer }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /guides/{guideId}/versions/{number}/diff:
    parameters:
      - name: guideId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
      - name: number
        in: path
        required: true
        schema: { type: integer }
    get:
      tags: [Guides]
      operationId: diffGuideVersion
      summary: Step-level diff of a version against the current guide
      description: >
        Requires `guides.edit_any`. Scoped to the workspace in the path
        — a member of another workspace gets 403, never another tenant’s
        rows.
      responses:
        "200":
          description: Added / removed / changed steps (keyed by step id)
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    required: [added, removed, changed, meta_changed]
                    properties:
                      added: { type: array, items: { type: object } }
                      removed: { type: array, items: { type: object } }
                      changed:
                        type: array
                        items:
                          type: object
                          properties:
                            id: { type: string }
                            before: { type: object }
                            after: { type: object }
                      meta_changed: { type: boolean }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /guides/{guideId}/gif:
    parameters:
      - name: guideId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Guides]
      operationId: buildGuideGif
      summary: Render a step range as an animated GIF (async)
      description: >
        ATH-084 — returns a media id immediately; a queued job renders the
        frames (edited > annotated > raw screenshot variants). Poll
        getGuideGif until ready.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [from_seq, to_seq]
              properties:
                from_seq: { type: integer, minimum: 1 }
                to_seq: { type: integer, minimum: 1 }
      responses:
        "202":
          description: Render queued
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    required: [media_id]
                    properties:
                      media_id: { $ref: "#/components/schemas/Ulid" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /guides/{guideId}/gif/{mediaId}:
    parameters:
      - name: guideId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
      - name: mediaId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    get:
      tags: [Guides]
      operationId: getGuideGif
      summary: Poll a GIF render
      description: >
        Requires `guides.edit_any`. Scoped to the workspace in the path
        — a member of another workspace gets 403, never another tenant’s
        rows.
      responses:
        "200":
          description: Status (+ signed URL once ready)
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    required: [ready]
                    properties:
                      ready: { type: boolean }
                      url: { type: [string, "null"], format: uri }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /guides/{guideId}/lock:
    parameters:
      - name: guideId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Guides]
      operationId: acquireGuideLock
      summary: Acquire or heartbeat the editing soft lock (force = takeover)
      description: >
        ATH-087 — one editor per guide (Doc 03 §2.1). Locks go stale after
        90s without a heartbeat; takeover is always available. 409 carries
        the current holder.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                force: { type: boolean }
      responses:
        "200":
          description: Held by the caller
          content:
            application/json:
              schema: { $ref: "#/components/schemas/GuideLockState" }
        "409":
          description: Held by someone else
          content:
            application/json:
              schema: { $ref: "#/components/schemas/GuideLockState" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Guides]
      operationId: releaseGuideLock
      summary: Release the caller's lock (no-op if not the holder)
      description: >
        Requires `guides.edit_any`. Scoped to the workspace in the path
        — a member of another workspace gets 403, never another tenant’s
        rows.
      responses:
        "204": { description: Released }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /guides/{guideId}/comments:
    parameters:
      - name: guideId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    get:
      tags: [Guides]
      operationId: listGuideComments
      summary: Comments on a guide (optionally step-anchored)
      description: >
        Requires `guides.view`. Scoped to the workspace in the path — a
        member of another workspace gets 403, never another tenant’s
        rows.
      responses:
        "200":
          description: Oldest first
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Comment" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Guides]
      operationId: createGuideComment
      summary: Comment on a guide or one of its steps
      description: >
        Requires `guides.view`. Scoped to the workspace in the path — a
        member of another workspace gets 403, never another tenant’s
        rows.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [body]
              properties:
                body: { type: string, maxLength: 5000 }
                step_id: { type: [string, "null"] }
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/Comment" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /guides/{guideId}/comments/{commentId}:
    parameters:
      - name: guideId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
      - name: commentId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    patch:
      tags: [Guides]
      operationId: resolveGuideComment
      summary: Resolve or reopen a comment (author or editor)
      description: >
        Requires `guides.view`. Scoped to the workspace in the path — a
        member of another workspace gets 403, never another tenant’s
        rows.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [resolved]
              properties:
                resolved: { type: boolean }
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/Comment" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Guides]
      operationId: deleteGuideComment
      summary: Delete a comment (author or editor)
      description: >
        Requires `guides.view`. Scoped to the workspace in the path — a
        member of another workspace gets 403, never another tenant’s
        rows.
      responses:
        "204": { description: Deleted }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /guides/{guideId}/media:
    parameters:
      - name: guideId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Guides]
      operationId: uploadGuideMedia
      summary: Upload a replacement screenshot for the editor
      description: >
        ATH-082 — sha256 content-addressed like capture media; a re-upload
        dedupes to the existing asset. Referenced by step.replace_screenshot.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file: { type: string, format: binary }
      responses:
        "201":
          description: Stored (or deduped to an existing asset)
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/MediaAsset" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  # ── Public API (ATH-250, Doc 06 §4) ─────────────────────────────────────
  # Every operation below is API-key authenticated and tagged PublicApi.
  # There is no workspace in these paths — the key names the tenant, the
  # same contract the Retrieval API established in ATH-258 — so a key
  # from workspace A physically cannot address workspace B's rows.

  /contacts:
    get:
      tags: [Contacts, PublicApi]
      operationId: publicListContacts
      summary: "List contacts (ability: contacts:read)"
      description: >
        ATH-250 — cursor-paginated over ULID `id` descending (newest
        first). The cursor is an opaque `id`; paginating on a
        non-nullable, monotonic, unique column is what keeps a page
        boundary stable while rows are being written underneath it,
        which a timestamp cursor cannot promise.
      parameters:
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
        - name: email
          in: query
          description: Exact match, case-insensitive. The dedupe key an integration syncs on.
          schema: { type: string, format: email }
        - name: updated_since
          in: query
          description: Only contacts seen at or after this instant — the incremental-sync filter.
          schema: { type: string, format: date-time }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/PaginatedList"
                  - type: object
                    required: [data]
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/PublicContact" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ValidationError" }
        "429": { description: Throttled }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Contacts, PublicApi]
      operationId: publicCreateContact
      summary: "Create or upsert a contact by email (ability: contacts:write)"
      description: >
        ATH-250 — upsert semantics on `email`, which is the workspace's
        unique key for a contact. An integration replaying its backlog
        must not create duplicates, and asking every caller to GET
        before POST would make that a race rather than a guarantee; a
        repeat POST therefore updates and returns 200, a first POST
        creates and returns 201. Blank incoming values never clobber
        stored ones, matching the CSV import's rule. Subject to the
        plan's contact ceiling (422 when full).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email: { type: string, format: email }
                name: { type: [string, "null"] }
                phone: { type: [string, "null"] }
                external_id:
                  type: [string, "null"]
                  description: The caller's own id for this person — what a CRM sync joins on.
                attributes:
                  type: object
                  description: Freeform scalar custom attributes, max 30 keys.
                  additionalProperties: true
      responses:
        "200":
          description: Existing contact updated (matched on email)
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/PublicContact" }
        "201":
          description: Contact created
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/PublicContact" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ValidationError" }
        "429": { description: Throttled }

  /contacts/{contactId}:
    parameters:
      - name: contactId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    get:
      tags: [Contacts, PublicApi]
      operationId: publicGetContact
      summary: "Read one contact (ability: contacts:read)"
      description: >
        ATH-250 — a contact in another workspace is 404, never 403: a
        403 would confirm the id exists somewhere, which is a
        cross-tenant disclosure however small.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/PublicContact" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { description: Throttled }
    patch:
      tags: [Contacts, PublicApi]
      operationId: publicUpdateContact
      summary: "Update a contact (ability: contacts:write)"
      description: >
        ATH-250 — partial update; omitted fields are left alone. `email`
        is not updatable here: it is the dedupe key, so changing it
        would silently merge or split identities. Re-POST to /contacts
        to upsert against a different email.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: [string, "null"] }
                phone: { type: [string, "null"] }
                external_id: { type: [string, "null"] }
                attributes:
                  type: object
                  additionalProperties: true
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/PublicContact" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }
        "429": { description: Throttled }

  /conversations:
    get:
      tags: [Inbox, PublicApi]
      operationId: publicListConversations
      summary: "List conversations (ability: conversations:read)"
      description: >
        ATH-250 — cursor-paginated over ULID `id` descending, same
        contract as /contacts. Read-only in v1: REPLYING through the
        public API sends a message to a real customer under the
        workspace's name, and that deserves its own ticket with the
        channel-window rules (WhatsApp's 24h, ATH-180) and the
        suppression list wired in, rather than being smuggled in behind
        a parity checkbox here.
      parameters:
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
        - name: state
          in: query
          schema: { type: string, enum: [pending, open, snoozed, resolved] }
        - name: channel
          in: query
          schema: { type: string }
        - name: contact_id
          in: query
          schema: { $ref: "#/components/schemas/Ulid" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/PaginatedList"
                  - type: object
                    required: [data]
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/PublicConversation" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ValidationError" }
        "429": { description: Throttled }

  /conversations/{conversationId}:
    parameters:
      - name: conversationId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    get:
      tags: [Inbox, PublicApi]
      operationId: publicGetConversation
      summary: "Read one conversation with its thread (ability: conversations:read)"
      description: >
        ATH-250 — the conversation plus its messages, oldest first.
        Internal notes (`direction=note`) are EXCLUDED: they are staff
        commentary written in the belief that the customer will never
        see them, and an integration token is not staff. Another
        workspace's id is 404, never 403.
      parameters:
        - name: message_limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    allOf:
                      - $ref: "#/components/schemas/PublicConversation"
                      - type: object
                        required: [messages]
                        properties:
                          messages:
                            type: array
                            items: { $ref: "#/components/schemas/PublicMessage" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }
        "429": { description: Throttled }

  # ── Public webhook management (ATH-253, PA-250, Doc 06 §5/§6) ───────────
  # A Zapier trigger and an n8n trigger node are REST hooks: the platform
  # calls `subscribe` when a user turns the automation on and
  # `unsubscribe` when they turn it off. ATH-251 built the whole delivery
  # side — signing, retries, auto-disable, redelivery — but exposed
  # authoring to a session principal only, which left `webhooks:read` and
  # `webhooks:write` grantable abilities that unlocked no route (PA-250).
  # These four operations close that gap; they are the same machinery,
  # reached by a token instead of a cookie.

  /webhook-event-types:
    get:
      tags: [Webhooks, PublicApi]
      operationId: publicListWebhookEventTypes
      summary: "List subscribable event types (ability: webhooks:read)"
      description: >
        ATH-253 — the deliverable event catalog, derived at runtime from
        the events that implement `PubliclyDeliverable` (ATH-251). An
        integration builder renders this as the event picker, so a name
        that is not here cannot be subscribed to and a name that is here
        is guaranteed to be able to arrive.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/WebhookEventType" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { description: Throttled }

  /webhooks:
    get:
      tags: [Webhooks, PublicApi]
      operationId: publicListWebhooks
      summary: "List this workspace's webhook endpoints (ability: webhooks:read)"
      description: >
        ATH-253 — cursor-paginated, newest first. An iPaaS platform
        needs this to RECONCILE: a Zap deleted while Cairn was
        unreachable leaves an endpoint nobody will ever unsubscribe, and
        without a list call the only way to find it is the portal. The
        signing secret is never included — it is returned exactly once,
        by the create call.
      parameters:
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/PaginatedList"
                  - type: object
                    required: [data]
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/PublicWebhookEndpoint" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { description: Throttled }
    post:
      tags: [Webhooks, PublicApi]
      operationId: publicCreateWebhook
      summary: "Subscribe a URL to events (ability: webhooks:write)"
      description: >
        ATH-253 — the `subscribe` half of a REST hook. Creates an
        ATH-251 endpoint and its subscriptions in one call and returns
        the HMAC signing `secret` EXACTLY ONCE, in this response body.
        A trigger implementation is expected to keep that secret with
        the subscription and verify `Cairn-Signature` on every
        delivery; there is no reveal endpoint, because "show me the
        secret again" and "rotate the secret" are the same operation
        from a security standpoint and only one of them leaves the
        existing verifier working.


        `url` is validated by ATH-202's DNS-free SSRF guard here and by
        the resolving guard on every delivery attempt.
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PublicWebhookInput" }
      responses:
        "201":
          description: Subscribed. The `secret` in this body is not retrievable again.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/PublicWebhookEndpointCreated" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }
        "429": { description: Throttled }

  /webhooks/{webhookId}:
    parameters:
      - name: webhookId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    delete:
      tags: [Webhooks, PublicApi]
      operationId: publicDeleteWebhook
      summary: "Unsubscribe and delete an endpoint (ability: webhooks:write)"
      description: >
        ATH-253 — the `unsubscribe` half of a REST hook. Subscriptions,
        deliveries and attempts cascade. Another workspace's id is 404,
        never 403.
      responses:
        "204": { description: Deleted }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { description: Throttled }

  # ── Retrieval API (ATH-258, Doc 06 §7) ──────────────────────────────────

  /retrieval/query:
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Retrieval, PublicApi]
      operationId: queryRetrieval
      summary: Hybrid retrieval over the workspace knowledge corpus
      description: >
        ATH-258 — vector + FTS retrieval fused by RRF (the ATH-154
        engine) for non-MCP consumers and the Cairn MCP server.
        Requires a workspace API key with the `retrieval:query` ability;
        the key names the tenant, so results can never cross workspaces.
        Audience governance (Doc 07 §5): keys see `public` + `customers`
        chunks; `internal` chunks require the `retrieval:internal`
        ability and are silently excluded otherwise — requesting an
        audience the key may not see narrows to the permitted set
        (fail closed), it never errors content into view.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [query]
              properties:
                query: { type: string, minLength: 2, maxLength: 500 }
                top_k:
                  type: integer
                  minimum: 1
                  maximum: 25
                  default: 8
                filters:
                  type: object
                  properties:
                    audiences:
                      type: array
                      description: Subset of audiences to search; intersected with what the key may see
                      items: { type: string, enum: [public, customers, internal] }
      responses:
        "200":
          description: Ranked chunks with source attribution, best first
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/RetrievalHit" }
                  meta:
                    type: object
                    required: [audiences]
                    properties:
                      audiences:
                        type: array
                        description: The audiences actually searched after key-scope intersection
                        items: { type: string, enum: [public, customers, internal] }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ValidationError" }
        "429": { description: Throttled }

  /retrieval/guides/{guideId}:
    parameters:
      - name: guideId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    get:
      tags: [Retrieval, PublicApi]
      operationId: retrievalGetGuide
      summary: Full published guide behind a retrieval hit (API-key principal)
      description: >
        ATH-258 — the document read behind a `guide:{id}` hit, for MCP
        `cairn_get_guide`. Requires a workspace API key with the
        `guides:read` ability. Published guides only, and only those
        whose share-mode audience the key may see (private guides map
        to `internal` and need `retrieval:internal`); anything else is
        404 — existence is never confirmed across the audience fence.
      responses:
        "200":
          description: Full guide with steps and signed media URLs
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/Guide" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { description: Throttled }

  /retrieval/articles/{articleId}:
    parameters:
      - name: articleId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    get:
      tags: [Retrieval, PublicApi]
      operationId: retrievalGetArticle
      summary: Full published article behind a retrieval hit (API-key principal)
      description: >
        ATH-258 — the document read behind an `article:{id}` hit, for
        MCP `cairn_get_article`. Requires a workspace API key with the
        `articles:read` ability. Published articles only, and only
        those whose access-level audience the key may see (`internal`
        access needs `retrieval:internal`); anything else is 404 —
        existence is never confirmed across the audience fence.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/Article" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { description: Throttled }

  # ── Workflows (Doc 05 §6, ATH-191) ──────────────────────────────────────
  /workspaces/{workspaceId}/workflow-node-types:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    get:
      tags: [Workflows]
      operationId: listWorkflowNodeTypes
      summary: The node palette — types + per-type config schema for the editor
      description: >
        ATH-191 — every node type registered on the runtime's NodeRegistry
        with the metadata the canvas editor renders its palette and per-node
        config forms from. PA-276 adds `timeout_minutes` to the
        `ask_question` descriptor so authored waits have a visible,
        bounded lifetime. Requires `workflows.manage`. Read-only,
        available on any plan.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/WorkflowNodeType" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /workspaces/{workspaceId}/workflow-templates:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    get:
      tags: [Workflows]
      operationId: listWorkflowTemplates
      summary: The pre-built workflow library, with the placeholders each one needs
      description: >
        ATH-194 — the first-party template library (Doc 05 §6 "template
        library"). Requires `workflows.manage`. Read-only and available on
        any plan, so the workflow list page can show what the Growth plan
        would unlock; INSTANTIATING one is the gated write.


        Templates are code-defined and versioned with the application, not
        workspace rows: they are product content, and a shipped guard
        (WorkflowTemplateTest) proves every one of them still names
        registered node types, still names a trigger-eligible event, and
        still instantiates into a graph the editor's own validator accepts.


        Each template declares the `placeholders` a workspace must fill in
        before the graph means anything — which segment, which tool, which
        message copy. Every placeholder is a string; a required one with no
        `default` must be supplied to instantiate.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/WorkflowTemplate" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /workspaces/{workspaceId}/workflows/from-template/{templateId}:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - name: templateId
        in: path
        required: true
        description: The template slug from listWorkflowTemplates.
        schema: { type: string, maxLength: 64 }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Workflows]
      operationId: createWorkflowFromTemplate
      summary: Instantiate a pre-built template as a new DRAFT workflow
      description: >
        ATH-194 — requires `workflows.manage` AND the
        `feature.workflows.builder` entitlement (422 below it). Copies the
        template's graph into a new workflow in this workspace with the
        supplied placeholder values substituted, and returns it exactly as
        createWorkflow would.


        The result is always a DRAFT, never published: a template is a
        starting point an author is expected to read, adjust and simulate,
        and putting a flow in front of customers stays a deliberate human
        act. `graph` is therefore null on the response and `draft_graph`
        carries the instantiated graph.


        A missing required placeholder, a blank one, or a key the template
        does not declare is a 422 under `error.details.placeholders.<key>`.
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WorkflowTemplateInput" }
      responses:
        "201":
          description: Created — a draft workflow built from the template
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/Workflow" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /workspaces/{workspaceId}/workflows:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    get:
      tags: [Workflows]
      operationId: listWorkflows
      summary: List workflows (cursor-paginated, newest first)
      description: >
        ATH-191 — requires `workflows.manage`. Scoped to the workspace in
        the path — a member of another workspace gets 403, never another
        tenant's rows.
      parameters:
        - { name: status, in: query, schema: { type: string, enum: [draft, published, archived] } }
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/PaginatedList"
                  - type: object
                    required: [data]
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/WorkflowSummary" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Workflows]
      operationId: createWorkflow
      summary: Create a workflow (starts as a draft)
      description: >
        ATH-191 — requires `workflows.manage` AND the
        `feature.workflows.builder` entitlement (422 below it). A graph, if
        supplied, is validated server-side; when omitted a minimal
        trigger-only starter graph is seeded so the canvas has an entry
        node. Requires `workflows.manage`. Scoped to the workspace in the
        path — a member of another workspace gets 403, never another
        tenant's rows.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WorkflowInput" }
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/Workflow" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /workspaces/{workspaceId}/workflows/{workflowId}:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - name: workflowId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    get:
      tags: [Workflows]
      operationId: getWorkflow
      summary: Full workflow incl. the draft graph and the active (published) graph
      description: >
        ATH-191 — requires `workflows.manage`. Scoped to the workspace in
        the path — a member of another workspace gets 403, never another
        tenant's rows.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/Workflow" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Workflows]
      operationId: updateWorkflow
      summary: Update the draft (name, trigger, and/or the draft graph)
      description: >
        ATH-191 — requires `workflows.manage` AND the
        `feature.workflows.builder` entitlement (422 below it). A supplied
        `graph` is the DRAFT graph and is validated server-side: every
        node's type must be registered and its config valid, edges must
        reference existing nodes, exactly one trigger must exist, every
        node must be reachable from it, and no synchronous loop may exist
        that the runtime's step guard would only trip at run time.
        Validation failures return 422 with per-node field errors under
        `error.details` (keyed `graph.nodes.<id>` / `graph`). The active
        (published) graph is untouched until `publish`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WorkflowUpdate" }
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/Workflow" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }
    delete:
      tags: [Workflows]
      operationId: deleteWorkflow
      summary: Delete a workflow
      description: >
        ATH-191 — requires `workflows.manage`. Available on any plan so a
        downgraded workspace can still clean up. Scoped to the workspace in
        the path — a member of another workspace gets 403, never another
        tenant's rows.
      responses:
        "204": { description: Deleted }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /workspaces/{workspaceId}/workflows/{workflowId}/publish:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - name: workflowId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Workflows]
      operationId: publishWorkflow
      summary: Promote the draft graph to the active (published) graph
      description: >
        ATH-191 — requires `workflows.manage` AND the
        `feature.workflows.builder` entitlement (422 below it). Re-validates
        the draft graph (422 on failure) then copies it to the active graph,
        flips status to `published`, and bumps `version` on re-publish. From
        here the runtime (ATH-190) triggers the workflow.
      responses:
        "200":
          description: Published
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/Workflow" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /workspaces/{workspaceId}/workflows/{workflowId}/unpublish:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - name: workflowId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Workflows]
      operationId: unpublishWorkflow
      summary: Return a published workflow to draft (stops triggering it)
      description: >
        ATH-191 — requires `workflows.manage` AND the
        `feature.workflows.builder` entitlement (422 below it). Flips status
        to `draft`; the runtime stops triggering it. The last-published
        graph stays on the row so re-publishing without edits is a no-op
        promotion. In-flight runs finish against their own snapshot.
      responses:
        "200":
          description: Unpublished
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/Workflow" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /workspaces/{workspaceId}/workflows/{workflowId}/simulate:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - name: workflowId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Workflows]
      operationId: simulateWorkflow
      summary: Dry-run the DRAFT graph against a hypothetical subject
      description: >
        ATH-193 — requires `workflows.manage` AND the
        `feature.workflows.builder` entitlement (422 below it). Walks the
        DRAFT graph (that is the point — an author sees what an unpublished
        change would do) against a hypothetical subject and returns the
        full step trace, branch by branch, with the run context after each
        step. The draft is re-validated first, so an invalid graph 422s with
        the same per-node keys the canvas already pins.


        A simulation has NO side effects. It never persists a workflow run,
        never emits a domain event, and never reaches a real customer:
        every node type that could touch anything outside the run is
        replaced by a stand-in that reports what it WOULD do. Node types
        the simulator does not know how to stand in for are refused rather
        than executed, so a newly registered node cannot silently perform a
        real side effect here.


        Parks are fast-forwarded rather than awaited: a delay reports
        "would wait 3 days" and execution continues; a question reports
        that it would wait for a reply and takes the scripted `answers`
        entry so both branches can be explored. `outcomes` scripts the
        branch a non-deterministic node (ai_answer, action) would take.
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WorkflowSimulationInput" }
      responses:
        "200":
          description: The simulated trace
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/WorkflowSimulation" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  # ── Tools (ATH-202) ─────────────────────────────────────────────────────
  /workspaces/{workspaceId}/tools:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    get:
      tags: [Tools]
      operationId: listTools
      summary: List the workspace's registered agent tools
      description: >
        ATH-202 — requires `integrations.manage`. Returns every tool the
        workspace has registered, of every kind. Credential PAYLOADS are
        never included; a tool reports only whether a vault credential is
        linked and under which key. Scoped to the workspace in the path —
        a member of another workspace gets 403, never another tenant's
        rows.
      parameters:
        - name: kind
          in: query
          schema: { type: string, enum: [builtin, http, mcp] }
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/PaginatedList"
                  - type: object
                    required: [data]
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/ToolSummary" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Tools]
      operationId: createTool
      summary: Register a tool (http kind = the endpoint builder)
      description: >
        ATH-202 — requires `integrations.manage` and the
        `feature.ai.tool_builder` flag. A `kind=http` tool's `config` is
        validated server-side before it is stored — endpoint URL against
        the SSRF guard's DNS-free rules, method against the read/write
        allow-list, templates against the declared parameter names, and
        static headers against carrying credentials (those belong in the
        vault). New tools are always created DISABLED — enabling is a
        separate PATCH, after the author has exercised the test-invoke
        endpoint.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ToolInput" }
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/Tool" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /workspaces/{workspaceId}/tools/{toolId}:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - name: toolId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    get:
      tags: [Tools]
      operationId: getTool
      summary: A single tool including its full configuration
      description: >
        ATH-202 — requires `integrations.manage`. Scoped to the workspace
        in the path — another workspace's toolId is a 404, never a read.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/Tool" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Tools]
      operationId: updateTool
      summary: Update a tool's name, configuration, credential link, or enabled flag
      description: >
        ATH-202 — requires `integrations.manage` and the
        `feature.ai.tool_builder` flag. A supplied `config` is fully
        re-validated; enabling a tool whose config no longer validates is
        refused with 422 rather than left to fail at call time. Linking a
        credential names a vault KEY belonging to this workspace — a key
        from another tenant is a validation error, not a silent null.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ToolUpdate" }
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/Tool" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }
    delete:
      tags: [Tools]
      operationId: deleteTool
      summary: Delete a tool
      description: >
        ATH-202 — requires `integrations.manage`. The linked vault
        credential is NOT deleted: one credential can back several tools
        and removing a definition must not revoke a secret out from under
        the others.
      responses:
        "204": { description: Deleted }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /workspaces/{workspaceId}/tools/import-preview:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Tools]
      operationId: previewToolImport
      summary: Parse an OpenAPI document and list the operations importable as tools
      description: >
        ATH-202 — requires `integrations.manage` and the
        `feature.ai.tool_builder` flag. Give either `url` (fetched
        through the same SSRF guard as a tool call — a spec URL is the
        same vector) or `document` (a pasted JSON or YAML spec). Nothing
        is persisted; the response carries a ready-to-store `config` per
        importable operation plus an explicit `refusals` list naming
        every operation that was skipped and why. The importer supports
        the common shape (OpenAPI 3.0/3.1, path/query/header parameters,
        JSON request bodies, local $ref) and REFUSES the rest loudly
        rather than generating a tool that would call the wrong thing.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ToolImportRequest" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/ToolImportPreview" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /workspaces/{workspaceId}/tools/{toolId}/test-invoke:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - name: toolId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Tools]
      operationId: testInvokeTool
      summary: Run a tool once as its author, to check it before enabling it
      description: >
        ATH-202 — requires `integrations.manage` and the
        `feature.ai.tool_builder` flag. Runs the tool with the supplied
        arguments and returns the mapped result envelope, so an author
        can see exactly what an agent would receive. Works on a DISABLED
        tool by design: a tool is fail-closed until someone has confirmed
        it works, so a try-it that honoured the enabled flag could never
        be used.


        ATH-204 NARROWED THIS BYPASS. Only the `enabled` flag is skipped
        here. Permission rules are evaluated on this route exactly as
        they are for an agent, with caller kind `human` — so a rule that
        denies the call denies it here too, and a rule that requires
        approval opens an approval task and returns
        `status=pending_approval` WITHOUT running the tool. The earlier
        contract skipped the whole authorizer, which let an operator run
        a rule-denied or approval-gated tool straight from this button.


        The SSRF guard, timeout, and response cap all still apply. When
        the call is allowed the request really is made, so a write-verb
        tool really writes.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ToolInvocationRequest" }
      responses:
        "200":
          description: >
            The envelope reports ok, error, denied, or pending_approval.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/ToolInvocationResult" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  # ── Tool permission rules (ATH-204) ─────────────────────────────────────
  /workspaces/{workspaceId}/tools/{toolId}/permission-rules:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - name: toolId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    get:
      tags: [Tools]
      operationId: listToolPermissionRules
      summary: List the permission rules governing a tool
      description: >
        ATH-204 (Doc 05 §5) — requires `integrations.manage` and the
        `feature.ai.tool_approvals` flag. Rules are returned in
        EVALUATION order (priority ascending, then id). The first rule
        whose caller kind and argument constraints match decides the
        call. If no rule matches the call is DENIED — the engine is
        fail-closed, mirroring the `enabled` column's default-false
        posture, so a tool with no rules cannot be run by anything.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/PaginatedList"
                  - type: object
                    required: [data]
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/ToolPermissionRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Tools]
      operationId: createToolPermissionRule
      summary: Add a permission rule to a tool
      description: >
        ATH-204 — requires `integrations.manage` and the
        `feature.ai.tool_approvals` flag. A rule pairs a caller kind
        (`agent`, `workflow`, `human`, or `any`) and an optional set of
        argument constraints with an effect: `allow` runs the tool,
        `require_approval` opens an approval task and runs the tool only
        after a human approves, `deny` refuses outright. Constraints are
        ANDed; a constraint whose argument is missing or of the wrong
        type does NOT match, so a `refund <= 5000` rule can never be
        satisfied by an absent amount.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ToolPermissionRuleInput" }
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/ToolPermissionRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /workspaces/{workspaceId}/tools/{toolId}/permission-rules/{ruleId}:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - name: toolId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
      - name: ruleId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    patch:
      tags: [Tools]
      operationId: updateToolPermissionRule
      summary: Update a permission rule
      description: >
        ATH-204 — requires `integrations.manage` and the
        `feature.ai.tool_approvals` flag. Editing a rule changes what
        FUTURE calls are allowed to do; approval tasks already opened
        keep the arguments and the effect they were created with, so a
        pending refund cannot change size because someone edited the
        rule while it sat in the queue.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ToolPermissionRuleUpdate" }
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/ToolPermissionRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }
    delete:
      tags: [Tools]
      operationId: deleteToolPermissionRule
      summary: Delete a permission rule
      description: >
        ATH-204 — requires `integrations.manage` and the
        `feature.ai.tool_approvals` flag. Deleting the last rule for a
        tool does not disable the tool, it makes every call to it fail
        closed — which is the intended way to stop an agent using a tool
        without tearing down its configuration.
      responses:
        "204": { description: Deleted }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ── External MCP servers (ATH-203) ──────────────────────────────────────
  /workspaces/{workspaceId}/mcp-servers:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    get:
      tags: [MCP]
      operationId: listMcpServers
      summary: List the external MCP servers this workspace has attached
      description: >
        ATH-203 — requires `integrations.manage` and the
        `feature.ai.mcp_client` flag (404 without it). Credential
        PAYLOADS are never included; a server reports only whether a
        vault credential is linked and under which key. Scoped to the
        workspace in the path.
      parameters:
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/PaginatedList"
                  - type: object
                    required: [data]
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/McpServer" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [MCP]
      operationId: createMcpServer
      summary: Attach an external MCP server
      description: >
        ATH-203 — requires `integrations.manage` and the
        `feature.ai.mcp_client` flag. The endpoint URL is checked
        against the SSRF guard's DNS-free rules before it is stored, and
        again with DNS resolution on every outbound call. Only the
        remote `http` transport (MCP Streamable HTTP) is accepted;
        stdio is refused, because running a local process per workspace
        is a different isolation problem than this platform solves.
        Creating a server discovers NOTHING — call the discover
        operation to see what it offers.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/McpServerInput" }
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/McpServer" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /workspaces/{workspaceId}/mcp-servers/{serverId}:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - name: serverId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    get:
      tags: [MCP]
      operationId: getMcpServer
      summary: A single attached MCP server
      description: >
        ATH-203 — requires `integrations.manage`. Another workspace's
        serverId is a 404, never a read.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/McpServer" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [MCP]
      operationId: updateMcpServer
      summary: Update an attached MCP server
      description: >
        ATH-203 — requires `integrations.manage`. `slug` and `transport`
        are immutable: every tool discovered from this server carries
        the slug in its own, and those tool slugs are stable so that
        permission rules and audit history stay attached. Setting
        `enabled` to false is the kill switch — every tool on this
        server stops executing without any of them being deleted.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/McpServerUpdate" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/McpServer" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }
    delete:
      tags: [MCP]
      operationId: deleteMcpServer
      summary: Detach an MCP server and delete the tools discovered from it
      description: >
        ATH-203 — requires `integrations.manage`. The tools discovered
        from this server are deleted with it: a tool that can never
        execute again must not keep sitting in the tool list reading
        `enabled`. Their `agent_actions` audit history SURVIVES (the
        FK nulls), so the record of what the agent did outlives the
        tool it did it with. The linked vault credential also survives —
        one credential can back several connections.
      responses:
        "204": { description: Deleted }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /workspaces/{workspaceId}/mcp-servers/{serverId}/discover:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - name: serverId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [MCP]
      operationId: discoverMcpServerTools
      summary: Handshake, list the server's tools, and materialise them as tool rows
      description: >
        ATH-203 — requires `integrations.manage` and the
        `feature.ai.mcp_client` flag. Performs the MCP handshake
        (`initialize` + `notifications/initialized`) and `tools/list`,
        then reconciles the result into `kind=mcp` tool rows.


        IDEMPOTENT. Re-running matches on (server, remote tool name) and
        UPDATES in place, so a tool keeps its id, slug, permission rules
        and audit history across any number of refreshes. It never
        duplicates a row and it NEVER sets `enabled` to true — a tool an
        admin disabled stays disabled however many times the server is
        re-listed.


        TRUST. Discovered tools are created DISABLED with only an
        operator-tier permission rule, so the agent cannot call a newly
        discovered tool at all until someone writes a rule saying it
        may. A tool whose definition (name, description, argument
        schema) has CHANGED since it was enabled is switched back off
        with `review_reason: definition_changed` — approval attaches to
        what a human reviewed, not to a name the server can refill. A
        tool the server no longer offers is marked
        `review_reason: removed_upstream` and disabled, never deleted.


        Failures (SSRF refusal, handshake error, timeout, oversized
        response) are 422 on `url` and are recorded on the server row.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/McpDiscoveryReport" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  # ── Tool approval queue (ATH-204) ───────────────────────────────────────
  /workspaces/{workspaceId}/tool-approvals:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    get:
      tags: [Inbox]
      operationId: listToolApprovals
      summary: The human queue of tool calls waiting on approval
      description: >
        ATH-204 (Doc 05 §5, acceptance scenario 4) — requires
        `inbox.view` and the `feature.ai.tool_approvals` flag. An
        approval task is created when a permission rule says a call is
        allowed but needs a human, and the tool has NOT run when the
        task appears here. Tasks carry the exact arguments the call was
        made with, frozen at request time. Scoped to the workspace in
        the path — another tenant's queue is never visible.
      parameters:
        - name: status
          in: query
          schema: { type: string, enum: [pending, approved, denied, expired] }
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/PaginatedList"
                  - type: object
                    required: [data]
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/ToolApprovalSummary" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /workspaces/{workspaceId}/tool-approvals/{approvalId}:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - name: approvalId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    get:
      tags: [Inbox]
      operationId: getToolApproval
      summary: One approval task including the arguments the call would run with
      description: >
        ATH-204 — requires `inbox.view` and the
        `feature.ai.tool_approvals` flag. Shows the frozen arguments, the
        rule that demanded approval, who asked, when it expires, and —
        once settled — who decided and what the execution returned.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/ToolApproval" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /workspaces/{workspaceId}/tool-approvals/{approvalId}/approve:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - name: approvalId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Inbox]
      operationId: approveToolApproval
      summary: Approve a pending tool call and queue it for execution
      description: >
        ATH-204 — requires `inbox.reply` and the
        `feature.ai.tool_approvals` flag. The tool runs asynchronously
        with the arguments FROZEN at request time; nothing in this
        request body can change what is executed. Approval is a
        single-shot state transition claimed with a conditional update,
        so a double-approve — two operators clicking at once, or a
        retried request — executes the tool exactly once and returns 409
        `approval_already_decided` for the loser. An expired task cannot
        be approved.
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ToolApprovalDecision" }
      responses:
        "200":
          description: Approved; execution is queued.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/ToolApproval" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /workspaces/{workspaceId}/tool-approvals/{approvalId}/deny:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - name: approvalId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Inbox]
      operationId: denyToolApproval
      summary: Deny a pending tool call so it never runs
      description: >
        ATH-204 — requires `inbox.reply` and the
        `feature.ai.tool_approvals` flag. The tool is never executed.
        Like approval this is a single-shot transition, so denying an
        already-decided task returns 409 `approval_already_decided`
        rather than overwriting the record. A workflow run parked on this
        task is woken and continues with a denied outcome.
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ToolApprovalDecision" }
      responses:
        "200":
          description: Denied; the tool will not run.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/ToolApproval" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /workspaces/{workspaceId}/agent-actions:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    get:
      tags: [Tools]
      operationId: listAgentActions
      summary: The audit log of every tool invocation made through the platform
      description: >
        ATH-205 (Doc 05 §5 "full audit in `agent_actions` table") —
        requires `ai.view_runs` and the `feature.ai.agent_actions` flag.
        One row per invocation attempt, whoever made it: the AI agent,
        a workflow action node, or an operator's try-it click. The row
        records who called, which tool, with what arguments, what the
        permission engine decided, who approved it when a human had to,
        and what came of it.


        REDACTION. Arguments and results are stored REDACTED and
        TRUNCATED — values under keys that look like secrets are
        replaced with `[redacted]`, long strings are cut, and large
        structures are capped. `redacted_keys` names the paths that were
        masked so a reader can tell "there was nothing here" from "there
        was something here and we did not keep it". Credential material
        from the per-workspace vault is never part of an audit row at
        all: it is injected downstream of the record and is not carried
        on the event the audit listens to.


        RETENTION (PA-268). The arguments and result have an END DATE;
        the rest of the row does not. Past the payload retention window
        a daily sweep empties those two columns and stamps
        `payload_scrubbed_at` — the invocation record itself (who asked,
        which tool, what was decided, who approved, how it ended, when)
        is kept, because that record IS the audit. A workspace may also
        opt out of storing the payloads at all, from the first write,
        via `settings.tools.store_action_payloads`. Both states are
        DECLARED on the row rather than silent: see
        `payload_scrubbed_at` and `payload_scrub_reason` on the detail
        response.


        Ordering is newest-first by `id` (ULIDs are time-ordered), which
        is a non-nullable column, so the cursor is stable across
        engines. Scoped to the workspace in the path — another tenant's
        actions are never visible.
      parameters:
        - name: tool_id
          in: query
          description: Only actions for this tool.
          schema: { $ref: "#/components/schemas/Ulid" }
        - name: caller_kind
          in: query
          description: Only actions made by this kind of caller.
          schema: { type: string, enum: [agent, workflow, human] }
        - name: status
          in: query
          description: Only actions that ended in this state.
          schema:
            type: string
            enum: [ok, error, denied, pending_approval, expired]
        - name: from
          in: query
          description: Only actions that occurred at or after this instant.
          schema: { type: string, format: date-time }
        - name: to
          in: query
          description: Only actions that occurred at or before this instant.
          schema: { type: string, format: date-time }
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/PaginatedList"
                  - type: object
                    required: [data]
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/AgentActionSummary" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /workspaces/{workspaceId}/agent-actions/{actionId}:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - name: actionId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    get:
      tags: [Tools]
      operationId: getAgentAction
      summary: One tool invocation with its redacted arguments and result
      description: >
        ATH-205 — requires `ai.view_runs` and the
        `feature.ai.agent_actions` flag. The full record: provenance,
        the rule that decided, the redacted arguments and result, the
        approver when there was one, and the outcome.


        PA-268 — the payloads may legitimately be absent, and the row
        SAYS SO when they are: an empty `arguments` with a non-null
        `payload_scrubbed_at` means "there was something here and its
        retention window passed", not "the call carried no arguments".
        Always read `payload_scrubbed_at` before drawing a conclusion
        from an empty payload.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/AgentAction" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ── "Do it for me" execution audit (PA-220, Doc 07 §2.2) ────────────────

  /workspaces/{workspaceId}/automation-runs:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    get:
      tags: [Automation]
      operationId: listAutomationRuns
      summary: The audit log of every "do it for me" execution run
      description: >
        PA-220 (Doc 07 §2.2 — "every executed step logged
        (`automation_runs`, screenshots optional)") — requires
        `ai.view_runs` and the `feature.automation.runs` flag. One row
        per execution SESSION: the agent-driven runtime executing an
        executable guide's steps one at a time on the customer's own app,
        under a consent HUD. The row records who launched it, which guide
        (its title snapshotted so the record outlives the guide), the
        host it acted on, and how it ended.


        NO TYPED VALUES (CLAUDE.md rule 3). This is the read half of an
        audit spine that structurally cannot hold captured input. A run's
        steps record WHICH field a `fill` touched (`selector_used`) and
        THAT a fill happened — never the text entered. A step handed to
        the user for manual entry because the field was masked or
        sensitive is flagged `sensitive_manual`, again with no value.


        Ordering is newest-first by `id` (ULIDs are time-ordered), a
        non-nullable column, so the cursor is stable across engines.
        Scoped to the workspace in the path — another tenant's runs are
        never visible.
      parameters:
        - name: guide_id
          in: query
          description: Only runs that executed this guide.
          schema: { $ref: "#/components/schemas/Ulid" }
        - name: status
          in: query
          description: Only runs that ended in this state.
          schema:
            type: string
            enum: [running, completed, aborted, failed]
        - name: initiator_kind
          in: query
          description: Only runs launched by this kind of initiator.
          schema: { type: string, enum: [visitor, member, agent] }
        - name: from
          in: query
          description: Only runs that started at or after this instant.
          schema: { type: string, format: date-time }
        - name: to
          in: query
          description: Only runs that started at or before this instant.
          schema: { type: string, format: date-time }
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/PaginatedList"
                  - type: object
                    required: [data]
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/AutomationRunSummary" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /workspaces/{workspaceId}/automation-runs/{runId}:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - name: runId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    get:
      tags: [Automation]
      operationId: getAutomationRun
      summary: One execution run with its executed steps
      description: >
        PA-220 — requires `ai.view_runs` and the `feature.automation.runs`
        flag. The full run with its ordered steps. For each step: the
        action, WHICH selector resolved (`selector_used`), the outcome,
        whether it was handed to the user for manual entry
        (`sensitive_manual`), and an optional screenshot STORAGE
        REFERENCE — never an inline image, and never a typed value
        (CLAUDE.md rule 3).
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/AutomationRun" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }


  # ── "Do it for me" domain allowlist (PA-219, Doc 07 §2.2 safety rails) ───

  /workspaces/{workspaceId}/automation-allowed-domains:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    get:
      tags: [Automation]
      operationId: listAutomationAllowedDomains
      summary: Product domains the "do it for me" engine may act on
      description: >
        PA-219 (Doc 07 §2.2 — "only runs on customer's own product domains
        (workspace-verified domain allowlist)") — requires
        `settings.security` and the `feature.automation.allowlist` flag
        (404 below it). The execution engine (PA-218) may drive a page
        ONLY when its host has a `verified` row here; adding a host is not
        enough, ownership must be proven. This is the security boundary,
        enforced server-side — a client-side check is bypassable.


        Each row carries the `verification_token` the workspace must serve
        at `well_known_path` on the host to prove control of it. Status
        moves pending → verified/failed via the async check.
      responses:
        "200":
          description: Allowed domains, newest first
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/AutomationAllowedDomain" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Automation]
      operationId: addAutomationAllowedDomain
      summary: Add a product domain (starts ownership verification)
      description: >
        PA-219 — requires `settings.security` and the
        `feature.automation.allowlist` flag. The host is normalised
        (lowercased, scheme/port/path stripped) and must be unique within
        the workspace. Verification is asynchronous: the row returns
        `pending` with a `verification_token`, and the workspace proves
        control by serving that token at `well_known_path` over HTTPS,
        after which the check flips the row to `verified`. Only a
        `verified` host authorises execution.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/AutomationAllowedDomainInput" }
      responses:
        "201":
          description: Domain registered, verification queued
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/AutomationAllowedDomain" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /workspaces/{workspaceId}/automation-allowed-domains/{domainId}:
    parameters:
      - { $ref: "#/components/parameters/WorkspaceId" }
      - name: domainId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    get:
      tags: [Automation]
      operationId: getAutomationAllowedDomain
      summary: One allowed domain with its verification state
      description: >
        PA-219 — requires `settings.security` and the
        `feature.automation.allowlist` flag. Another tenant's id 404s
        under the workspace scope (rule 2).
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/AutomationAllowedDomain" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Automation]
      operationId: removeAutomationAllowedDomain
      summary: Remove an allowed domain
      description: >
        PA-219 — removing the row immediately withdraws execution
        authorisation for the host: the guard reads this table live, so
        the next run on that host is refused.
      responses:
        "204": { description: Removed }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /workspaces/{workspaceId}/automation-allowed-domains/{domainId}/verify:
    parameters:
      - { $ref: "#/components/parameters/WorkspaceId" }
      - name: domainId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Automation]
      operationId: verifyAutomationAllowedDomain
      summary: Re-run the ownership verification check
      description: >
        PA-219 — the "check it now" endpoint. A domain is added `pending`
        BEFORE the workspace has served the token, so its first automatic
        check fails; once the token is in place the workspace calls this
        to re-check. Queues a fresh check; poll the list or show endpoint
        for the outcome. Idempotent and throttled.
      responses:
        "202":
          description: Verification queued
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/AutomationAllowedDomain" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { description: Throttled }


  # ── Proactive guidance: page↔knowledge map + pinning (PA-228, Doc 07 §4) ─

  /workspaces/{workspaceId}/knowledge-pins:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    get:
      tags: [Guidance]
      operationId: listKnowledgePins
      summary: Guides pinned to URL patterns
      description: >
        PA-228 (Doc 07 §4) — the manual half of the page↔knowledge map:
        every guide a workspace has pinned to a URL pattern, newest first.
        A pin makes a guide relevant to the pages its `url_pattern`
        matches, optionally anchored to one element by `selector`
        (element-level pinning, Scribe parity). Requires `guides.edit_any`
        and the `feature.guidance.page_map` flag (404 below it).
      responses:
        "200":
          description: Pins, newest first
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/KnowledgePin" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Guidance]
      operationId: createKnowledgePin
      summary: Pin a guide to a URL pattern
      description: >
        PA-228 — pin a guide so it surfaces on pages matching
        `url_pattern`. `match_type` chooses how the pattern is compared to
        a page URL: `exact` uses the tolerant walkthrough-player rule
        (origin + path equality up to a trailing slash, query subset, hash
        only when the pattern carries one — the same clients/walkthrough
        `urlMatches` semantics the auto map uses, so client and server
        agree), `prefix` matches a path and everything under it, `glob`
        matches a `*` wildcard path. `selector` is optional and pins the
        guide to one element. One pin per (guide, url_pattern) per
        workspace. Requires `guides.edit_any` and the
        `feature.guidance.page_map` flag.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/KnowledgePinInput" }
      responses:
        "201":
          description: Pin created
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/KnowledgePin" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /workspaces/{workspaceId}/knowledge-pins/{pinId}:
    parameters:
      - { $ref: "#/components/parameters/WorkspaceId" }
      - name: pinId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    patch:
      tags: [Guidance]
      operationId: updateKnowledgePin
      summary: Update a pin's pattern, match type, or selector
      description: >
        PA-228 — change where a pinned guide surfaces. The guide a pin
        points at is fixed; re-pin to move a guide to another. Another
        tenant's pin id 404s under the workspace scope (rule 2). Requires
        `guides.edit_any` and the `feature.guidance.page_map` flag.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/KnowledgePinUpdateInput" }
      responses:
        "200":
          description: Pin updated
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/KnowledgePin" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }
    delete:
      tags: [Guidance]
      operationId: deleteKnowledgePin
      summary: Remove a pin
      description: >
        PA-228 — unpin a guide from a URL pattern. The AUTO association
        (if the guide has a step whose captured URL matches) is
        unaffected — only the manual pin is removed. Requires
        `guides.edit_any` and the `feature.guidance.page_map` flag.
      responses:
        "204": { description: Removed }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /workspaces/{workspaceId}/page-knowledge-map:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    get:
      tags: [Guidance]
      operationId: queryPageKnowledgeMap
      summary: Guides relevant to a page URL
      description: >
        PA-228 (Doc 07 §4) — given a page URL, the guides relevant there,
        ranked pinned-first. This is THE read the nudge engine (PA-229)
        and the employee Sidekick (PA-230) build on. Two sources are
        merged and de-duplicated by guide: MANUAL pins whose `url_pattern`
        matches the URL (carrying the optional `selector`), and AUTO
        associations — guides with a step whose captured URL matches,
        using the same tolerant `urlMatches` rule the walkthrough player
        uses, so client and server agree on what "matches". A guide that
        is both pinned and auto-matched appears once, as `pinned`. AUTO
        entries are limited to published guides; a pinned entry carries
        its guide's status so the caller can decide. Requires
        `guides.view` (every member — so the Sidekick's audience is not
        locked out) and the `feature.guidance.page_map` flag (404 below
        it). Matching runs in PHP over the workspace's own pins and step
        URLs — never engine-specific SQL — so sqlite and Postgres agree.
      parameters:
        - name: url
          in: query
          required: true
          description: The page URL to resolve relevant guides for.
          schema: { type: string, maxLength: 2048 }
      responses:
        "200":
          description: Relevant guides, pinned first
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/PageKnowledgeMapEntry" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /workspaces/{workspaceId}/sidekick/search:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Guidance]
      operationId: runSidekickSearch
      summary: Sidekick — internal-audience knowledge search for employees
      description: >
        PA-230 (Doc 07 §4) — the copilot-search half of the employee
        Sidekick: a conversation-less, member-authed knowledge query that
        returns a grounded, cited answer over the workspace's `internal`
        audience ONLY. It is the standalone counterpart to the
        conversation-scoped agent-assist copilot (`runCopilot`): same
        retrieval + grounding + citation components, but no conversation,
        no draft, and no delivery.


        AUDIENCE SCOPE is the correctness property. Retrieval is pinned to
        `internal` in the Action (never a caller parameter), so this
        surface can never return `customers`- or `public`-audience chunks,
        and — like every retrieval leg — it is workspace-scoped in SQL, so
        another tenant's `internal` knowledge is unreachable. Every cited
        source is therefore `internal`.


        Requires `ai.view_runs` — an AI permission (this spends AI credits
        and reads internal knowledge), analyst-grade and NOT admin-only, so
        the internal team the Sidekick exists for can use it. Plan-gated on
        `feature.guidance.sidekick` (404 below it, rule 7). Every call is
        metered through the LLM gateway and debits AI credits; an exhausted
        workspace gets 402. Grounding floor as the copilot: when nothing
        internal answers the query it declines and charges nothing rather
        than inventing.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SidekickSearchRequest" }
      responses:
        "200":
          description: A grounded internal-audience answer, or a decline
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/SidekickAnswer" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402":
          description: AI credits exhausted — top up to keep using the Sidekick
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }


  # ── Outbound webhooks (ATH-251, Doc 06 §5) ──────────────────────────────

  /workspaces/{workspaceId}/webhook-event-types:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    get:
      tags: [Webhooks]
      operationId: listWebhookEventTypes
      summary: Event types an endpoint may subscribe to
      description: >
        ATH-251 — requires `webhooks.manage`. The catalog is DERIVED from
        the domain events that have opted in to public delivery, never
        hand-maintained, so it cannot drift from what is actually
        deliverable. Subscribing to anything outside it is a 422.
      responses:
        "200":
          description: The subscribable event catalog
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/WebhookEventType" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /workspaces/{workspaceId}/webhook-endpoints:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    get:
      tags: [Webhooks]
      operationId: listWebhookEndpoints
      summary: Registered endpoints (cursor-paginated)
      description: >
        ATH-251 — requires `webhooks.manage`. Newest-first on the ULID
        primary key. The signing secret is never included; it is
        returned exactly once, by the create call.
      parameters:
        - { $ref: "#/components/parameters/Cursor" }
        - { $ref: "#/components/parameters/Limit" }
      responses:
        "200":
          description: A page of endpoints
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/WebhookEndpoint" }
                  meta:
                    type: object
                    properties:
                      next_cursor: { type: [string, "null"] }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Webhooks]
      operationId: createWebhookEndpoint
      summary: Register an endpoint and mint its signing secret
      description: >
        ATH-251 — requires `webhooks.manage`. The `url` is
        workspace-supplied and therefore attacker-controllable, so it is
        validated with ATH-202's OutboundUrlGuard DNS-free rules here
        (loopback, link-local, cloud-metadata, internal suffixes,
        credential-bearing and numerically-encoded hosts, and non-http(s)
        schemes are all 422 on the `url` field) and re-checked WITH DNS
        resolution on every delivery attempt.


        THE RESPONSE CARRIES THE SIGNING SECRET, and it is the only time
        it is ever returned. It is stored encrypted at rest and there is
        deliberately no reveal endpoint — a lost secret is rotated, not
        recovered.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WebhookEndpointInput" }
      responses:
        "201":
          description: Created. The `secret` field appears here and nowhere else.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/WebhookEndpointCreated" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /workspaces/{workspaceId}/webhook-endpoints/{endpointId}:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - name: endpointId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    get:
      tags: [Webhooks]
      operationId: getWebhookEndpoint
      summary: One endpoint with its subscriptions and health
      description: "ATH-251 — requires `webhooks.manage`. Includes the failure counter and, when it was switched off automatically, the reason."
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/WebhookEndpoint" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Webhooks]
      operationId: updateWebhookEndpoint
      summary: Edit subscriptions, pause, or re-enable an endpoint
      description: >
        ATH-251 — requires `webhooks.manage`. A changed `url` is
        re-validated against the SSRF guard exactly as on create.
        Setting `status` back to `enabled` after an automatic disable
        clears the failure counter and the recorded reason; it is the
        only way an auto-disabled endpoint resumes, because a
        redelivery must not quietly re-enable a host we have evidence
        is broken.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WebhookEndpointPatchInput" }
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/WebhookEndpoint" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }
    delete:
      tags: [Webhooks]
      operationId: deleteWebhookEndpoint
      summary: Remove an endpoint
      description: "ATH-251 — requires `webhooks.manage`. Its subscriptions, deliveries and attempt history go with it; nothing is delivered to it afterwards."
      responses:
        "204": { description: Deleted }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /workspaces/{workspaceId}/webhook-deliveries:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    get:
      tags: [Webhooks]
      operationId: listWebhookDeliveries
      summary: Delivery log (cursor-paginated)
      description: >
        ATH-251 — requires `webhooks.manage`. Newest-first on the ULID
        primary key. One row per event per endpoint, carrying the
        outcome, the attempt count and the last status code or error; the
        per-attempt detail is on the single-delivery route. A delivery
        refused by the SSRF guard appears here as a failure with the
        reason, never as a silent success.
      parameters:
        - { $ref: "#/components/parameters/Cursor" }
        - { $ref: "#/components/parameters/Limit" }
        - name: endpoint_id
          in: query
          schema: { $ref: "#/components/schemas/Ulid" }
        - name: status
          in: query
          schema:
            type: string
            enum: [pending, succeeded, failed]
      responses:
        "200":
          description: A page of deliveries
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/WebhookDelivery" }
                  meta:
                    type: object
                    properties:
                      next_cursor: { type: [string, "null"] }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /workspaces/{workspaceId}/webhook-deliveries/{deliveryId}:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - name: deliveryId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    get:
      tags: [Webhooks]
      operationId: getWebhookDelivery
      summary: One delivery with every attempt and the exact body sent
      description: >
        ATH-251 — requires `webhooks.manage`. `body` is the verbatim
        bytes that were signed and sent, so a customer debugging a
        signature mismatch can compare against what their server
        received. Each attempt carries its status code, duration and
        error.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/WebhookDeliveryDetail" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /workspaces/{workspaceId}/webhook-deliveries/{deliveryId}/redeliver:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - name: deliveryId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Webhooks]
      operationId: redeliverWebhookDelivery
      summary: Replay a delivery
      description: >
        ATH-251 — requires `webhooks.manage`. Creates a NEW delivery that
        replays the original's exact bytes; the original keeps its status
        and attempt history, because that history is the evidence behind
        an auto-disable and must not be erased by a retry click.


        The replay carries the ORIGINAL `Idempotency-Key`, so a receiver
        that already processed the event will correctly ignore it —
        redelivery exists for the case where the endpoint never got it.


        409 when the endpoint is disabled or the delivery is still being
        attempted. Re-enabling is a separate, explicit PATCH.
      responses:
        "201":
          description: The replay was queued
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/WebhookDelivery" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }

  /workspaces/{workspaceId}/slack/installation:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    get:
      tags: [Integrations]
      operationId: getSlackInstallation
      summary: The workspace's Slack connection, if any
      description: >
        ATH-254 — requires `integrations.manage`. Returns `data: null`
        when Slack is not connected (a 200 with an empty answer, not a
        404: "no connection" is the normal state of a settings screen,
        not a missing resource).


        The bot token is NEVER included and there is no reveal endpoint.
        It is written to the encrypted vault by the OAuth callback and
        read only by the delivery job.
      responses:
        "200":
          description: The connection, or null
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    oneOf:
                      - { $ref: "#/components/schemas/SlackInstallation" }
                      - { type: "null" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Integrations]
      operationId: updateSlackInstallation
      summary: Choose the channel new conversations are announced in
      description: >
        ATH-254 — requires `integrations.manage`. Setting
        `notify_channel_id` is what actually switches notifications on;
        a connected workspace with no channel chosen is connected but
        silent, which is the safe default for an integration that
        forwards customer messages into a chat room.


        Re-enabling after an automatic disable clears the failure
        counter and the recorded reason, exactly as ATH-251's endpoint
        PATCH does — and for the same reason: a workspace must
        acknowledge a broken connection rather than have a retry quietly
        resume one we have evidence is dead.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SlackInstallationPatchInput" }
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/SlackInstallation" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }
    delete:
      tags: [Integrations]
      operationId: disconnectSlack
      summary: Disconnect Slack
      description: >
        ATH-254 — requires `integrations.manage`. Three things happen and
        the order matters: the bot token is handed to Slack's
        `auth.revoke` so it stops working at THEIR end, it is deleted
        from our vault, and the installation row is stamped
        `revoked_at`.


        The row is stamped rather than deleted (ATH-252's
        `RevokeOAuthInstall` discipline) so "when did we stop talking to
        Slack" survives the disconnect, and re-connecting later reuses
        it. A revoke call Slack refuses does NOT abort the disconnect —
        the local token is destroyed regardless, because a disconnect
        that fails halfway must not leave a usable token behind.
        Idempotent: disconnecting an already-disconnected workspace
        keeps the original timestamp.
      responses:
        "204": { description: Disconnected }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /workspaces/{workspaceId}/slack/installation/authorize-url:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Integrations]
      operationId: startSlackInstall
      summary: Mint the Slack authorize URL that begins an install
      description: >
        ATH-254 — requires `integrations.manage`. Returns the
        `slack.com/oauth/v2/authorize` URL the admin's browser is sent
        to. Nothing is written; the installation row appears only when
        Slack calls back with a code.


        `state` is an HMAC-signed, short-lived token binding the
        workspace AND the user who started the flow, so the callback can
        re-derive both without trusting a query parameter. The callback
        re-checks `integrations.manage` against live membership, so a
        state minted by an admin who was demoted mid-flow is refused.


        422 when the Slack app is not configured on this deployment —
        the client credentials are ours, not the workspace's, and a
        connect button that leads to a Slack error page is worse than
        one that explains itself.
      responses:
        "201":
          description: The URL to send the browser to
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/SlackAuthorizeUrl" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /workspaces/{workspaceId}/slack/notifications:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    get:
      tags: [Integrations]
      operationId: listSlackNotifications
      summary: Outbound notification log (cursor-paginated)
      description: >
        ATH-254 — requires `integrations.manage`. Newest-first on the
        ULID primary key (never on a nullable timestamp). One row per
        attempted post to Slack, carrying the outcome, the attempt count
        and — the field that matters when debugging — `slack_error`, the
        `error` string Slack returned in a 200 OK body.


        Slack signals failure with HTTP 200 and `{"ok": false}`, so a
        transport-only log would show this integration as permanently
        healthy while every message was being rejected. Doc 06 §6 asks
        for per-integration health and logs; this is that surface.
      parameters:
        - { $ref: "#/components/parameters/Cursor" }
        - { $ref: "#/components/parameters/Limit" }
        - name: status
          in: query
          schema:
            type: string
            enum: [pending, succeeded, failed]
      responses:
        "200":
          description: A page of notifications
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/SlackNotification" }
                  meta:
                    type: object
                    properties:
                      next_cursor: { type: [string, "null"] }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ── SSO (SAML) ──────────────────────────────────────────────────────────
  /sso/saml/{workspaceId}/metadata:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    get:
      tags: [Sso]
      operationId: samlServiceProviderMetadata
      summary: SP metadata XML for this workspace
      security: []
      description: >
        ATH-260 — the document a customer uploads into their IdP. It is
        per-workspace, not per-installation, and the `entityID` plus the
        ACS URL both carry the workspace id. That is what makes the
        `Audience` restriction a tenancy check rather than a formality.


        Unauthenticated on purpose: it contains only public SP facts
        (entity id, ACS URL, binding, NameID format) and the IdP fetches
        it before any trust exists in either direction.
      responses:
        "200":
          description: SP metadata
          content:
            application/samlmetadata+xml:
              schema: { type: string }
        "404":
          description: >
            No SSO connection is configured for this workspace, or the
            workspace is not on a plan that includes SSO. Both answer
            404 rather than 403, so an unconfigured tenant is not
            distinguishable from a nonexistent one by an unauthenticated
            caller.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /sso/saml/{workspaceId}/login:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    get:
      tags: [Sso]
      operationId: startSamlLogin
      summary: SP-initiated login — redirect to the workspace's IdP
      security: []
      description: >
        ATH-260 — mints an `AuthnRequest`, persists its id server-side,
        and redirects to the IdP with a RelayState handle.


        The pending request is stored in the DATABASE, not the session.
        A browser returning from the IdP arrives on a cross-site POST,
        which does not carry `SameSite=Lax` cookies, so a session-backed
        request store would break exactly the flow it exists to secure
        and invite somebody to "fix" it by dropping the `InResponseTo`
        check. RelayState carries an opaque single-use handle; the ACS
        resolves it to the stored request id and hands THAT to the
        library, which then asserts it against the signed document.
      parameters:
        - name: redirect_to
          in: query
          schema: { type: string }
          description: >
            Portal path to land on after login. Validated as a relative
            path — an absolute URL is rejected rather than followed, so
            this cannot be turned into an open redirect.
      responses:
        "302":
          description: Redirect to the IdP SSO URL
        "404":
          description: SSO is not configured or not available on this plan
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /sso/saml/{workspaceId}/acs:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    post:
      tags: [Sso]
      operationId: consumeSamlAssertion
      summary: Assertion Consumer Service — consume a SAML response and log in
      security: []
      description: >
        ATH-260 — the security core of this ticket. Accepts the IdP's
        `SAMLResponse` form POST (HTTP-POST binding) and establishes a
        portal session, or refuses.


        No `Idempotency-Key`: the caller is an IdP's browser redirect,
        not an Cairn client, and this endpoint's replay semantics are
        the opposite of replay-safe. A repeated assertion MUST be
        refused, never served a cached success.


        EVERY one of the following must hold, and each has a test that
        breaks the guard and confirms the refusal turns red:


        the XML signature verifies against the certificate stored for
        THIS workspace; `NotBefore` and `NotOnOrAfter` bracket the
        current time; `Audience` equals this workspace's SP entity id;
        `Issuer` equals this workspace's configured IdP entity id; for
        SP-initiated flows `InResponseTo` matches a pending request this
        workspace issued and has not consumed; for IdP-initiated flows
        the connection must opt in AND the response must carry no
        `InResponseTo` at all; the assertion `ID` has not been seen
        before for this workspace; and the subject resolves to a member
        of THIS workspace, or the connection permits just-in-time
        provisioning.


        Every refusal is the same 403 with the same body. Somebody
        probing an ACS should not be able to tell an unknown workspace
        from a bad signature from a replayed assertion; the real reason
        goes to the workspace's audit log.
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema: { $ref: "#/components/schemas/SamlAcsInput" }
      responses:
        "302":
          description: Assertion accepted — session established, redirect to the portal
        "403":
          description: >
            Assertion refused. Deliberately undifferentiated; the reason
            is in the workspace's audit log, not in this response.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          description: SSO is not configured or not available on this plan
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /workspaces/{workspaceId}/sso/connection:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    get:
      tags: [Sso]
      operationId: getSsoConnection
      summary: Read this workspace's SAML connection
      description: >
        ATH-260 — requires `settings.security`. The IdP certificate is
        never returned; a SHA-256 fingerprint stands in for it, so an
        admin can confirm WHICH certificate is loaded without the
        response becoming a copy of it.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/SsoConnection" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    put:
      tags: [Sso]
      operationId: putSsoConnection
      summary: Create or replace the SAML connection
      description: >
        ATH-260 — requires `settings.security`. The certificate goes to
        the encrypted vault (CLAUDE.md rule 10) and never comes back.


        `enforced` is the lockout-relevant field and it is NOT freely
        settable. It may only be turned on once this connection has
        completed at least one successful login, which is recorded on
        the row. Enforcement is a promise that the IdP works, and the
        only evidence that an IdP works is that somebody used it.


        Two further guards make total lockout unreachable: workspace
        `owner`s are never subject to enforcement, and the last
        remaining owner can never be deprovisioned by SCIM. See
        `deprovisionScimUser`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SsoConnectionInput" }
      responses:
        "200":
          description: Existing connection replaced
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/SsoConnection" }
        "201":
          description: >
            First connection for this workspace created. PUT is an upsert
            of a singleton resource, so the status distinguishes the
            create from the replace — a client that only ever expects 200
            should treat both as success.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/SsoConnection" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ValidationError" }
    delete:
      tags: [Sso]
      operationId: deleteSsoConnection
      summary: Remove the SAML connection
      description: >
        ATH-260 — requires `settings.security`. Deleting the connection
        also drops enforcement, the vaulted certificate and every
        pending auth request. Members fall back to password login
        immediately; this is the deliberate manual half of the lockout
        escape hatch.
      responses:
        "204": { description: Deleted }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ── SCIM administration (house-shaped) ──────────────────────────────────
  /workspaces/{workspaceId}/scim/tokens:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    get:
      tags: [Scim]
      operationId: listScimTokens
      summary: Bearer tokens the IdP uses to reach this workspace's SCIM endpoints
      description: >
        ATH-260 — requires `settings.security`. Tokens are stored as a
        SHA-256 digest, exactly like ATH-250 API keys and ATH-252 client
        secrets. The plaintext existed once, in the create response, and
        is not recoverable here or anywhere else; the short non-secret
        prefix is what lets an admin tell two tokens apart.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/ScimToken" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      tags: [Scim]
      operationId: createScimToken
      summary: Mint a SCIM bearer token
      description: >
        ATH-260 — requires `settings.security`. The response is the only
        place the plaintext token ever exists outside the IdP.
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ScimTokenInput" }
      responses:
        "201":
          description: Token minted
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ScimTokenCreated" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /workspaces/{workspaceId}/scim/tokens/{tokenId}:
    parameters:
      - { $ref: "#/components/parameters/WorkspaceId" }
      - name: tokenId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Ulid" }
    delete:
      tags: [Scim]
      operationId: revokeScimToken
      summary: Revoke a SCIM bearer token
      description: >
        ATH-260 — requires `settings.security`. Stamps `revoked_at`
        rather than deleting the row, so the audit trail survives the
        revocation. Idempotent — revoking twice keeps the first
        timestamp.
      responses:
        "204": { description: Revoked }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /workspaces/{workspaceId}/scim/group-mappings:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    get:
      tags: [Scim]
      operationId: listScimGroupMappings
      summary: IdP group to Cairn role mappings
      description: >
        ATH-260 — requires `settings.security`. Roles come from the
        existing RBAC catalog; `owner` is not assignable from a
        directory group.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/ScimGroupMapping" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    put:
      tags: [Scim]
      operationId: putScimGroupMappings
      summary: Replace the whole group-to-role mapping set
      description: >
        ATH-260 — requires `settings.security`. Whole-set replace rather
        than per-item CRUD: the mapping table is small, an admin reasons
        about it as one policy, and a partial update is how you end up
        with a stale row nobody remembers granting.


        `role` must be a key of the RBAC catalog and may not be `owner`.
        A directory group conferring `billing.manage` and
        `workspace.delete` is not a mapping anyone should be able to
        make by typing a group name.
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ScimGroupMappingsInput" }
      responses:
        "200":
          description: Mappings replaced
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/ScimGroupMapping" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ValidationError" }

  # ── SCIM 2.0 wire protocol (RFC 7643 / RFC 7644 shapes) ─────────────────
  /scim/v2/{workspaceId}/ServiceProviderConfig:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    get:
      tags: [Scim]
      operationId: scimServiceProviderConfig
      summary: RFC 7643 §5 service provider capabilities
      security: []
      description: >
        ATH-260 — what this SCIM implementation supports. IdPs read it
        during connector setup to decide whether to send PATCH or PUT
        and whether to attempt filtering.


        `security: []` means only that this path does not use the house
        bearer scheme. It still requires a workspace SCIM bearer token,
        like every other path under `/scim/v2`.
      responses:
        "200":
          description: OK
          content:
            application/scim+json:
              schema: { $ref: "#/components/schemas/ScimServiceProviderConfig" }
        "401":
          description: Missing or invalid SCIM bearer token
          content:
            application/scim+json:
              schema: { $ref: "#/components/schemas/ScimError" }

  /scim/v2/{workspaceId}/Users:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    get:
      tags: [Scim]
      operationId: listScimUsers
      summary: List provisioned users
      security: []
      description: >
        ATH-260 — RFC 7644 §3.4.2. Supports the one filter every IdP
        actually sends, `userName eq "..."`, which is how a connector
        decides between create and update. Pagination is SCIM's 1-based
        `startIndex` and `count`, not the house cursor convention,
        because the client is an off-the-shelf connector that will not
        learn a new one.
      parameters:
        - name: filter
          in: query
          schema: { type: string }
        - name: startIndex
          in: query
          schema: { type: integer, minimum: 1, default: 1 }
        - name: count
          in: query
          schema: { type: integer, minimum: 0, maximum: 200, default: 100 }
      responses:
        "200":
          description: SCIM ListResponse
          content:
            application/scim+json:
              schema: { $ref: "#/components/schemas/ScimListResponse" }
        "401":
          description: Missing or invalid SCIM bearer token
          content:
            application/scim+json:
              schema: { $ref: "#/components/schemas/ScimError" }
    post:
      tags: [Scim]
      operationId: createScimUser
      summary: Provision a user into the workspace
      security: []
      description: >
        ATH-260 — RFC 7644 §3.3. Creates or re-attaches an Cairn user
        and gives them a membership in THIS workspace.


        This does NOT open a second membership path. It resolves the
        user by email and then goes through the same
        `ProvisionWorkspaceMember` action the invitation-accept flow
        uses, so a SCIM-created member is indistinguishable from an
        invited one in `workspace_members` and inherits every downstream
        behaviour, including ATH-030 per-member overrides.


        No `Idempotency-Key`: the caller is an IdP connector that has
        never heard of the header. SCIM's own idempotency is the
        `userName` uniqueness check, which answers 409 on a duplicate as
        RFC 7644 §3.3 requires.
      requestBody:
        required: true
        content:
          application/scim+json:
            schema: { $ref: "#/components/schemas/ScimUser" }
          application/json:
            schema: { $ref: "#/components/schemas/ScimUser" }
      responses:
        "201":
          description: Provisioned
          content:
            application/scim+json:
              schema: { $ref: "#/components/schemas/ScimUser" }
        "400":
          description: Malformed SCIM payload
          content:
            application/scim+json:
              schema: { $ref: "#/components/schemas/ScimError" }
        "401":
          description: Missing or invalid SCIM bearer token
          content:
            application/scim+json:
              schema: { $ref: "#/components/schemas/ScimError" }
        "409":
          description: A user with this userName already exists in the workspace
          content:
            application/scim+json:
              schema: { $ref: "#/components/schemas/ScimError" }

  /scim/v2/{workspaceId}/Users/{scimUserId}:
    parameters:
      - { $ref: "#/components/parameters/WorkspaceId" }
      - name: scimUserId
        in: path
        required: true
        schema: { type: string }
    get:
      tags: [Scim]
      operationId: getScimUser
      summary: Read one provisioned user
      security: []
      description: ATH-260 — RFC 7644 §3.4.1.
      responses:
        "200":
          description: OK
          content:
            application/scim+json:
              schema: { $ref: "#/components/schemas/ScimUser" }
        "401":
          description: Missing or invalid SCIM bearer token
          content:
            application/scim+json:
              schema: { $ref: "#/components/schemas/ScimError" }
        "404":
          description: No such SCIM resource in this workspace
          content:
            application/scim+json:
              schema: { $ref: "#/components/schemas/ScimError" }
    put:
      tags: [Scim]
      operationId: replaceScimUser
      summary: Replace a provisioned user
      security: []
      description: >
        ATH-260 — RFC 7644 §3.5.1. Setting `active` to false here is a
        DEPROVISION and revokes access; the scope is documented once, on
        `deprovisionScimUser`.
      requestBody:
        required: true
        content:
          application/scim+json:
            schema: { $ref: "#/components/schemas/ScimUser" }
          application/json:
            schema: { $ref: "#/components/schemas/ScimUser" }
      responses:
        "200":
          description: Updated
          content:
            application/scim+json:
              schema: { $ref: "#/components/schemas/ScimUser" }
        "400":
          description: Malformed SCIM payload
          content:
            application/scim+json:
              schema: { $ref: "#/components/schemas/ScimError" }
        "401":
          description: Missing or invalid SCIM bearer token
          content:
            application/scim+json:
              schema: { $ref: "#/components/schemas/ScimError" }
        "404":
          description: No such SCIM resource in this workspace
          content:
            application/scim+json:
              schema: { $ref: "#/components/schemas/ScimError" }
    patch:
      tags: [Scim]
      operationId: patchScimUser
      summary: Partially update a provisioned user
      security: []
      description: >
        ATH-260 — RFC 7644 §3.5.2. This is how Okta and Entra actually
        deactivate somebody: `replace` on the `active` path with false.
        That single operation is the deprovision trigger.
      requestBody:
        required: true
        content:
          application/scim+json:
            schema: { $ref: "#/components/schemas/ScimPatchRequest" }
          application/json:
            schema: { $ref: "#/components/schemas/ScimPatchRequest" }
      responses:
        "200":
          description: Updated
          content:
            application/scim+json:
              schema: { $ref: "#/components/schemas/ScimUser" }
        "400":
          description: Malformed or unsupported PATCH operation
          content:
            application/scim+json:
              schema: { $ref: "#/components/schemas/ScimError" }
        "401":
          description: Missing or invalid SCIM bearer token
          content:
            application/scim+json:
              schema: { $ref: "#/components/schemas/ScimError" }
        "404":
          description: No such SCIM resource in this workspace
          content:
            application/scim+json:
              schema: { $ref: "#/components/schemas/ScimError" }
    delete:
      tags: [Scim]
      operationId: deprovisionScimUser
      summary: Deprovision a user — revoke workspace access
      security: []
      description: >
        ATH-260 — RFC 7644 §3.6. THIS ACTUALLY REVOKES, which is the
        whole point of wiring an IdP to a SaaS product. The exact scope,
        stated once so nobody has to infer it:


        REVOKED ALWAYS, scoped to this workspace — the
        `workspace_members` row is removed, and the tenancy middleware
        consults that table on EVERY request, so a live portal session
        and a Sanctum PAT both stop reaching this workspace on the very
        next request rather than at session expiry. Any pending
        invitation for that address is deleted. Every ATH-252 OAuth
        install this user granted in this workspace is revoked through
        `RevokeOAuthInstall`, which cascades to the access tokens that
        install issued — that one matters because an install's ceiling
        was derived from the granting user's permissions, and they no
        longer have any.


        REVOKED ADDITIONALLY, only when this was the user's LAST
        membership anywhere — their portal sessions are deleted and all
        their Sanctum PATs are dropped. Those credentials are
        user-global rather than workspace-scoped, so destroying them
        because ONE workspace deprovisioned would let workspace A sign
        the user out of workspace B.


        NOT REVOKED — workspace API keys (ATH-250). A workspace key is
        the workspace's credential, issued under `api_keys.manage` and
        outliving any individual member. Killing a server integration
        because a person left is an outage, not a security control.


        REVALIDATED — content `owner_id` (PA-253, now fixed). SCIM
        deprovisioning and manual removal both route through the same
        `RemoveWorkspaceMember` action and emit the same
        `WorkspaceMemberRemoved` event, and a listener on it nulls any
        content owner_id that pointed at the departing member so a review
        never comes due against somebody with no access. The nulled
        content is surfaced, not hidden — it is findable with the
        `unowned` filter on the review queue. A daily reconciliation
        (`cairn:reconcile-content-owners`) is the backstop for any
        removal path that reaches the database without emitting the event,
        which matters now that an IdP can deprovision in bulk and
        unattended. `verified_by` is deliberately left untouched: a past
        verification by a departed colleague is a true historical fact.


        The last remaining `owner` cannot be deprovisioned — that is the
        third layer of the lockout guard described on
        `putSsoConnection`.
      responses:
        "204": { description: Deprovisioned }
        "400":
          description: >
            Refused — deprovisioning this user would leave the workspace
            with no owner.
          content:
            application/scim+json:
              schema: { $ref: "#/components/schemas/ScimError" }
        "401":
          description: Missing or invalid SCIM bearer token
          content:
            application/scim+json:
              schema: { $ref: "#/components/schemas/ScimError" }
        "404":
          description: No such SCIM resource in this workspace
          content:
            application/scim+json:
              schema: { $ref: "#/components/schemas/ScimError" }

  /scim/v2/{workspaceId}/Groups:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    get:
      tags: [Scim]
      operationId: listScimGroups
      summary: List groups
      security: []
      description: ATH-260 — RFC 7644 §3.4.2 over Group resources.
      parameters:
        - name: filter
          in: query
          schema: { type: string }
        - name: startIndex
          in: query
          schema: { type: integer, minimum: 1, default: 1 }
        - name: count
          in: query
          schema: { type: integer, minimum: 0, maximum: 200, default: 100 }
      responses:
        "200":
          description: SCIM ListResponse
          content:
            application/scim+json:
              schema: { $ref: "#/components/schemas/ScimListResponse" }
        "401":
          description: Missing or invalid SCIM bearer token
          content:
            application/scim+json:
              schema: { $ref: "#/components/schemas/ScimError" }
    post:
      tags: [Scim]
      operationId: createScimGroup
      summary: Create a group and bind it to an Cairn role
      security: []
      description: >
        ATH-260 — the group's `displayName` selects a role by matching a
        configured mapping. An unmapped group is accepted and stored but
        confers nothing, which is the safe direction: an IdP pushing its
        whole group tree must not be able to invent privileges by naming
        a group `owner`.
      requestBody:
        required: true
        content:
          application/scim+json:
            schema: { $ref: "#/components/schemas/ScimGroup" }
          application/json:
            schema: { $ref: "#/components/schemas/ScimGroup" }
      responses:
        "201":
          description: Created
          content:
            application/scim+json:
              schema: { $ref: "#/components/schemas/ScimGroup" }
        "400":
          description: Malformed SCIM payload
          content:
            application/scim+json:
              schema: { $ref: "#/components/schemas/ScimError" }
        "401":
          description: Missing or invalid SCIM bearer token
          content:
            application/scim+json:
              schema: { $ref: "#/components/schemas/ScimError" }
        "409":
          description: A group with this displayName already exists
          content:
            application/scim+json:
              schema: { $ref: "#/components/schemas/ScimError" }

  /scim/v2/{workspaceId}/Groups/{scimGroupId}:
    parameters:
      - { $ref: "#/components/parameters/WorkspaceId" }
      - name: scimGroupId
        in: path
        required: true
        schema: { type: string }
    get:
      tags: [Scim]
      operationId: getScimGroup
      summary: Read one group
      security: []
      description: ATH-260 — RFC 7644 §3.4.1 over a Group resource.
      responses:
        "200":
          description: OK
          content:
            application/scim+json:
              schema: { $ref: "#/components/schemas/ScimGroup" }
        "401":
          description: Missing or invalid SCIM bearer token
          content:
            application/scim+json:
              schema: { $ref: "#/components/schemas/ScimError" }
        "404":
          description: No such SCIM resource in this workspace
          content:
            application/scim+json:
              schema: { $ref: "#/components/schemas/ScimError" }
    patch:
      tags: [Scim]
      operationId: patchScimGroup
      summary: Add or remove group members
      security: []
      description: >
        ATH-260 — RFC 7644 §3.5.2 against the `members` path. Adding a
        member applies the group's mapped role to that member; removing
        one returns them to the workspace default role.


        Group membership changes NEVER create or destroy a workspace
        membership — that is the Users endpoint's job — so a group PATCH
        cannot be used to smuggle somebody into a workspace.
      requestBody:
        required: true
        content:
          application/scim+json:
            schema: { $ref: "#/components/schemas/ScimPatchRequest" }
          application/json:
            schema: { $ref: "#/components/schemas/ScimPatchRequest" }
      responses:
        "200":
          description: Updated
          content:
            application/scim+json:
              schema: { $ref: "#/components/schemas/ScimGroup" }
        "400":
          description: Malformed or unsupported PATCH operation
          content:
            application/scim+json:
              schema: { $ref: "#/components/schemas/ScimError" }
        "401":
          description: Missing or invalid SCIM bearer token
          content:
            application/scim+json:
              schema: { $ref: "#/components/schemas/ScimError" }
        "404":
          description: No such SCIM resource in this workspace
          content:
            application/scim+json:
              schema: { $ref: "#/components/schemas/ScimError" }
    delete:
      tags: [Scim]
      operationId: deleteScimGroup
      summary: Delete a group
      security: []
      description: >
        ATH-260 — removes the group and returns its members to the
        workspace default role. It does NOT remove anyone from the
        workspace; losing a role is not losing access.
      responses:
        "204": { description: Deleted }
        "401":
          description: Missing or invalid SCIM bearer token
          content:
            application/scim+json:
              schema: { $ref: "#/components/schemas/ScimError" }
        "404":
          description: No such SCIM resource in this workspace
          content:
            application/scim+json:
              schema: { $ref: "#/components/schemas/ScimError" }

  /workspaces/{workspaceId}/crm/providers:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    get:
      tags: [Integrations]
      operationId: listCrmProviders
      summary: CRM adapters this deployment can connect
      description: >
        ATH-255 — requires `integrations.manage`. Doc 06 §6 names four
        CRMs; this returns the ones actually implemented, which is two
        (HubSpot, Pipedrive). Salesforce and Zoho are deliberately
        absent rather than listed-and-broken: a name in this list is a
        promise that connecting it works.


        `supports_refresh` tells the UI whether to expect an OAuth
        redirect or an API-token field, and is the axis the adapter seam
        was designed against.
      responses:
        "200":
          description: The shipped adapters
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/CrmProvider" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /workspaces/{workspaceId}/crm/connections:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    get:
      tags: [Integrations]
      operationId: listCrmConnections
      summary: The workspace's CRM connections
      description: >
        ATH-255 — requires `integrations.manage`. One row per connected
        provider; a workspace may sync to more than one at a time.


        Access and refresh tokens are NEVER included and there is no
        reveal endpoint. They are written to the encrypted vault and
        read only by the sync job.
      responses:
        "200":
          description: The connections
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/CrmConnection" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Integrations]
      operationId: connectCrm
      summary: Connect a CRM
      description: >
        ATH-255 — requires `integrations.manage`. The credential in the
        request body is written to the encrypted vault and to nowhere
        else; `crm_connections` has no token column.


        Reconnecting an existing provider REUSES the row rather than
        creating a second one, which is what preserves the external-id
        mappings in `crm_contact_links` — and therefore the idempotency.
        A fresh connection would orphan every mapping and the next sync
        would duplicate the workspace's entire contact book in the CRM.


        `direction` defaults to `push`: connecting a CRM must not
        rewrite a workspace's existing contacts on the strength of a
        checkbox. `conflict_strategy` is only ever consulted under
        `bidirectional`, and only for a genuine three-way conflict —
        see the CrmSyncRun `conflicts` field.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CrmConnectInput" }
      responses:
        "201":
          description: Connected
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/CrmConnection" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /workspaces/{workspaceId}/crm/connections/{connectionId}:
    parameters:
      - { $ref: "#/components/parameters/WorkspaceId" }
      - { $ref: "#/components/parameters/CrmConnectionId" }
    patch:
      tags: [Integrations]
      operationId: updateCrmConnection
      summary: Change sync direction, conflict policy or field mapping
      description: >
        ATH-255 — requires `integrations.manage`. `field_map` is the PII
        allowlist: a workspace narrows what leaves the building, and
        keys outside the syncable set are dropped rather than accepted.


        Re-enabling after an automatic disable clears the failure
        counter and the recorded reason, exactly as ATH-251's endpoint
        PATCH does — a workspace must acknowledge a broken connection
        rather than have a retry quietly resume one we have evidence is
        dead. A connection in `needs_reauth` cannot be re-enabled this
        way: only re-authorising fixes a revoked grant, so pretending
        otherwise would just re-break it on the next sync.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CrmConnectionPatchInput" }
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/CrmConnection" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }
    delete:
      tags: [Integrations]
      operationId: disconnectCrm
      summary: Disconnect a CRM
      description: >
        ATH-255 — requires `integrations.manage`. The vault credential
        is destroyed, which is the part that is immediate and total:
        afterwards no job, replay or operator can call that customer's
        CRM, because the token is gone rather than merely unreferenced.


        The connection row is stamped `revoked_at` rather than deleted
        (ATH-252's `RevokeOAuthInstall` discipline), and the contact
        links are KEPT. Discarding them would mean a workspace that
        reconnects re-creates every contact it already has in the CRM.
      responses:
        "204": { description: Disconnected }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /workspaces/{workspaceId}/crm/connections/{connectionId}/sync:
    parameters:
      - { $ref: "#/components/parameters/WorkspaceId" }
      - { $ref: "#/components/parameters/CrmConnectionId" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Integrations]
      operationId: startCrmSync
      summary: Queue a contact, or the whole workspace, for syncing
      description: >
        ATH-255 — requires `integrations.manage`. With `contact_id`,
        queues one contact; without, queues a backfill of every contact
        in the workspace.


        THIS IS EXPLICIT BECAUSE IT HAS TO BE. There is no
        `contact.created` domain event in `packages/core/src/Events`
        (only `contact.unsubscribed`), so there is nothing for a
        continuous sync to subscribe to — the same gap that left
        ATH-253's Zapier "New Contact" trigger unbuilt, tracked on
        PA-254. Emitting it means adding a dispatch to the contacts
        domain, which CLAUDE.md rule 6 reserves for that squad rather
        than for an integration ticket. Sync-on-contact-change is
        therefore NOT shipped; when the event lands, a listener calling
        the same action is the whole remaining change.


        Idempotent in two layers: a contact with a run already pending
        is not queued twice, and `crm_contact_links` makes even a
        genuinely duplicated run converge on the same CRM record rather
        than creating a second one.
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CrmSyncInput" }
      responses:
        "202":
          description: Queued
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/CrmSyncQueued" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /workspaces/{workspaceId}/crm/sync-runs:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    get:
      tags: [Integrations]
      operationId: listCrmSyncRuns
      summary: Contact sync log (cursor-paginated)
      description: >
        ATH-255 — requires `integrations.manage`. Newest-first on the
        ULID primary key (never on a nullable timestamp). One row per
        attempted sync of one contact.


        Two fields carry the information a status code cannot.
        `provider_error` is the vendor's own code — Pipedrive reports
        failure as HTTP 200 with `{"success": false}`, so a
        transport-only log would show this integration as permanently
        healthy while nothing synced. `conflicts` records BOTH values of
        every field where the two systems genuinely disagreed, so the
        value that lost the merge is discarded rather than lost.
      parameters:
        - { $ref: "#/components/parameters/Cursor" }
        - { $ref: "#/components/parameters/Limit" }
        - name: status
          in: query
          schema:
            type: string
            enum: [pending, succeeded, failed]
      responses:
        "200":
          description: A page of sync runs
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/CrmSyncRun" }
                  meta:
                    type: object
                    properties:
                      next_cursor: { type: [string, "null"] }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /workspaces/{workspaceId}/segment-source/connection:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    get:
      tags: [Integrations]
      operationId: showSegmentSource
      summary: The workspace's Segment source
      description: >
        ATH-257 — requires `integrations.manage`. Streams Cairn's domain
        events OUT to the workspace's Segment/CDP as `track`/`identify`
        calls, the mirror image of the outbound webhooks pointed at one
        fixed vendor endpoint. A workspace has exactly ONE source, so this
        is a singular resource with no id in the path; `data` is null when
        none is configured.


        The Segment write key is NEVER included and there is no reveal
        endpoint. It is written to the encrypted vault and read only by
        the delivery job.
      responses:
        "200":
          description: The source, or a null envelope
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    oneOf:
                      - { $ref: "#/components/schemas/SegmentSourceConnection" }
                      - { type: "null" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Integrations]
      operationId: connectSegmentSource
      summary: Connect (or re-key) the Segment source
      description: >
        ATH-257 — requires `integrations.manage`. The write key in the
        request body is written to the encrypted vault and to nowhere
        else; `segment_source_connections` has no key column.


        Reconnecting REUSES the row rather than creating a second one — a
        workspace has one source, and reusing the row keeps its delivery
        history across a re-key. `region` picks the Segment Tracking API
        host (`us` = api.segment.io, `eu` = events.eu1.segmentapis.com); a
        write key is minted for one region, and streaming EU-resident data
        to the US host is a residency violation, not a routing detail.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SegmentSourceConnectInput" }
      responses:
        "201":
          description: Connected
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/SegmentSourceConnection" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }
    patch:
      tags: [Integrations]
      operationId: updateSegmentSource
      summary: Change region or label, or re-enable after an automatic disable
      description: >
        ATH-257 — requires `integrations.manage`. Re-enabling after an
        automatic disable clears the failure counter and recorded reason,
        exactly as ATH-251's endpoint PATCH does — a workspace must
        acknowledge a broken source rather than have a retry quietly
        resume one we have evidence is dead (most often a write key the
        customer rotated in Segment, which is fixed by re-connecting with
        a new key rather than by re-enabling).
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SegmentSourcePatchInput" }
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/SegmentSourceConnection" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }
    delete:
      tags: [Integrations]
      operationId: disconnectSegmentSource
      summary: Disconnect the Segment source
      description: >
        ATH-257 — requires `integrations.manage`. The vault write key is
        destroyed, which is the part that is immediate and total:
        afterwards no job, replay or operator can stream another event to
        that customer's Segment workspace, because the key is gone rather
        than merely unreferenced. The connection row is stamped
        `revoked_at` rather than deleted (ATH-252's `RevokeOAuthInstall`
        discipline), so a reconnect reuses it.
      responses:
        "204": { description: Disconnected }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /workspaces/{workspaceId}/segment-source/deliveries:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    get:
      tags: [Integrations]
      operationId: listSegmentSourceDeliveries
      summary: Segment delivery log (cursor-paginated)
      description: >
        ATH-257 — requires `integrations.manage`. Newest-first on the ULID
        primary key (never on a nullable timestamp). One row per domain
        event streamed to Segment, with its own retry lifecycle. `message_id`
        is the Segment `messageId` — stable across retries, so Segment
        dedupes on it and a retried delivery is not double-counted in the
        warehouse.
      parameters:
        - { $ref: "#/components/parameters/Cursor" }
        - { $ref: "#/components/parameters/Limit" }
        - name: status
          in: query
          schema:
            type: string
            enum: [pending, succeeded, failed]
      responses:
        "200":
          description: A page of deliveries
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/SegmentSourceDelivery" }
                  meta:
                    type: object
                    properties:
                      next_cursor: { type: [string, "null"] }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /workspaces/{workspaceId}/content-push/targets:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    get:
      tags: [Integrations]
      operationId: listContentPushTargets
      summary: External knowledge tools this deployment can push to
      description: >
        ATH-245 — requires `integrations.manage`. Doc 06 §6 names four
        external tools to push OUT to; this returns the ones actually
        implemented, which is two (Confluence, Zendesk Guide). Notion and
        SharePoint are deliberately absent rather than listed-and-broken:
        a name in this list is a promise that connecting it works, and
        Notion in particular is a block-tree content model whose faithful
        converter is a ticket of its own, not a thin follow-up.
      responses:
        "200":
          description: The shipped targets
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/ContentPushTarget" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /workspaces/{workspaceId}/content-push/connections:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    get:
      tags: [Integrations]
      operationId: listContentPushConnections
      summary: The workspace's content-push connections
      description: >
        ATH-245 — requires `integrations.manage`. One row per connected
        target; a workspace may push to more than one at a time.


        The account email and API token are NEVER included and there is no
        reveal endpoint. They are written to the encrypted vault and read
        only by the push job.
      responses:
        "200":
          description: The connections
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/ContentPushConnection" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Integrations]
      operationId: connectContentPush
      summary: Connect an external knowledge tool
      description: >
        ATH-245 — requires `integrations.manage`. The credential in the
        request body is written to the encrypted vault and to nowhere
        else; `content_push_connections` has no token column.


        Reconnecting an existing target REUSES the row rather than creating
        a second one, which is what preserves the external-id mappings in
        `content_push_links` — and therefore the idempotency. A fresh
        connection would orphan every mapping and the next push would
        publish a second copy of every article into the customer's wiki.


        `base_url` is the customer's OWN Confluence/Zendesk host. It is
        validated at connect time against the outbound guard (an internal
        or single-label host is refused with 422) and re-checked on every
        push, because a public hostname can resolve to a private address
        later. `auto_push` defaults false: replicating published content
        into a third-party system on every publish is an opt-in choice.


        So does `auto_retract` (PA-269), and for the mirror-image reason:
        reaching back into a customer's own Confluence or Zendesk to
        unpublish a page is an action on data we do not own. Off, a
        retraction in Cairn leaves the remote copy exactly as it is.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ContentPushConnectInput" }
      responses:
        "201":
          description: Connected
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/ContentPushConnection" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /workspaces/{workspaceId}/content-push/connections/{connectionId}:
    parameters:
      - { $ref: "#/components/parameters/WorkspaceId" }
      - { $ref: "#/components/parameters/ContentPushConnectionId" }
    patch:
      tags: [Integrations]
      operationId: updateContentPushConnection
      summary: Change auto-push, auto-retract, label, destination container or target settings
      description: >
        ATH-245 — requires `integrations.manage`. Re-enabling after an
        automatic disable clears the failure counter and the recorded
        reason, exactly as ATH-251's endpoint PATCH does — a workspace
        must acknowledge a broken connection rather than have a retry
        quietly resume one we have evidence is dead. A connection in
        `needs_reauth` cannot be re-enabled this way: the target rejected
        the credential, so only reconnecting fixes it.


        PA-273 — this is also where a Zendesk connection's access
        controls are set (`target_settings.permission_group_id` /
        `user_segment_id`). It extends this endpoint rather than adding a
        second one, following PA-266: these are connection configuration,
        and configuration of one connection belongs on the one PATCH that
        configures it.


        `target_settings` is DEEP-MERGED into the stored bag, so sending
        one key never deletes its siblings (PA-270's regression, which
        this codebase has already fixed twice). An explicit `null`
        clears a key, and clearing means Cairn stops sending the field
        at all — it does NOT send a null to Zendesk, which would read as
        "visible to everyone" for `user_segment_id`. Keys the
        connection's target does not understand are refused with 422
        rather than stored: a Confluence connection accepts no
        `target_settings` keys at all today.


        The ids are Zendesk's own numeric ids, which a Guide admin reads
        out of the Zendesk admin UI. Cairn does not list them for you:
        an id-picker would have to read the vault credential from a web
        request (today only the push job does) and make a synchronous,
        paginated call to the customer's Zendesk from inside one (rule 5
        says queue anything external). Both are real design changes, so
        the picker is a separate ticket and this endpoint takes the ids
        directly.

      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ContentPushConnectionPatchInput" }
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/ContentPushConnection" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }
    delete:
      tags: [Integrations]
      operationId: disconnectContentPush
      summary: Disconnect an external knowledge tool
      description: >
        ATH-245 — requires `integrations.manage`. The vault credential is
        destroyed, which is the part that is immediate and total:
        afterwards no job, replay or operator can call that customer's
        Confluence or Zendesk, because the token is gone rather than merely
        unreferenced.


        The connection row is stamped `revoked_at` rather than deleted
        (ATH-252's `RevokeOAuthInstall` discipline), and the push links are
        KEPT. Discarding them would mean a workspace that reconnects
        publishes a second copy of every article it already has over there.
        Content already pushed is NOT removed from the remote — it is a
        copy under the customer's own governance, and disconnecting stops
        the writes rather than retracting them.
      responses:
        "204": { description: Disconnected }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /workspaces/{workspaceId}/content-push/connections/{connectionId}/push:
    parameters:
      - { $ref: "#/components/parameters/WorkspaceId" }
      - { $ref: "#/components/parameters/ContentPushConnectionId" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Integrations]
      operationId: startContentPush
      summary: Push a published article, or the whole help centre
      description: >
        ATH-245 — requires `integrations.manage`. With `article_id`,
        pushes one PUBLISHED article; without, backfills every published
        article in the workspace. A draft is refused (422): only published
        content leaves.


        Automatic on-publish push exists too and is NOT this endpoint: a
        listener on the existing `content.published` event (ATH-151)
        queues a push for every connection with `auto_push` on, for PUBLIC
        articles only. This endpoint is the manual "push now" / backfill,
        and — unlike the auto path — an operator invoking it may push a
        non-public published article, because that is an explicit human
        choice rather than an automation acting on content somebody kept
        off the open help centre.


        Idempotent in two layers: an article with a run already pending is
        not queued twice, and `content_push_links` makes even a genuinely
        duplicated run UPDATE the same remote page rather than publishing a
        second copy.
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ContentPushInput" }
      responses:
        "202":
          description: Queued
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/ContentPushQueued" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /workspaces/{workspaceId}/content-push/push-runs:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    get:
      tags: [Integrations]
      operationId: listContentPushRuns
      summary: Content push log (cursor-paginated)
      description: >
        ATH-245 — requires `integrations.manage`. Newest-first on the ULID
        primary key (never on a nullable timestamp). One row per attempted
        push of one article.


        Two fields carry what a status code cannot. `provider_error` is the
        target's own error code — Confluence answers a stale page version
        with 409, Zendesk wraps field errors in a 422 details object.
        `conversion_notes` records what the HTML→target conversion could
        not carry faithfully (a relative image URL that will not resolve
        remotely, an element with no target representation), so a lossy
        push is visible in the log rather than only in a diff nobody runs.
      parameters:
        - { $ref: "#/components/parameters/Cursor" }
        - { $ref: "#/components/parameters/Limit" }
        - name: status
          in: query
          schema:
            type: string
            enum: [pending, succeeded, failed]
      responses:
        "200":
          description: A page of push runs
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/ContentPushRun" }
                  meta:
                    type: object
                    properties:
                      next_cursor: { type: [string, "null"] }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /workspaces/{workspaceId}/ecommerce/providers:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    get:
      tags: [Integrations]
      operationId: listEcommerceProviders
      summary: E-commerce platforms this deployment can connect
      description: >
        ATH-256 — requires `integrations.manage`. Doc 06 §6 names four
        platforms; this returns the ones actually implemented, which is
        two (Shopify, WooCommerce). Adobe Commerce and PrestaShop are
        deliberately absent rather than listed-and-broken: a name in this
        list is a promise that connecting it works.


        `supports_oauth` tells the connect screen whether to collect a
        shop domain and an access token (Shopify) or a site URL and a
        consumer key/secret (WooCommerce) — the axis the adapter seam was
        designed against. `handles_inbound_webhooks` is false for
        WooCommerce, which sends none (see the schema).
      responses:
        "200":
          description: The shipped adapters
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/EcommerceProvider" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /workspaces/{workspaceId}/ecommerce/connections:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    get:
      tags: [Integrations]
      operationId: listEcommerceConnections
      summary: The workspace's connected stores
      description: >
        ATH-256 — requires `integrations.manage`. One row per connected
        store; a workspace may connect several at once, across platforms
        or even two on one platform.


        Access tokens and consumer secrets are NEVER included and there is
        no reveal endpoint. They are written to the encrypted vault and
        read only by the fetch path.
      responses:
        "200":
          description: The connections
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/EcommerceConnection" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      tags: [Integrations]
      operationId: connectEcommerceStore
      summary: Connect an e-commerce store
      description: >
        ATH-256 — requires `integrations.manage`. The credential in the
        request body is written to the encrypted vault and to nowhere
        else; `ecommerce_connections` has no token column.


        The body shape depends on `platform`. Shopify wants `store_domain`
        (`acme.myshopify.com`) and `access_token`; WooCommerce wants
        `store_url` (an https:// site URL) and a `consumer_key` /
        `consumer_secret` pair. A wrong-shaped body is a 422 naming the
        missing field.


        Reconnecting the same store REUSES its row rather than creating a
        second one, which keeps the webhook-dedup ledger's foreign key
        intact so an `app/uninstalled` webhook arriving twice during a
        reconnect is still processed once.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/EcommerceConnectInput" }
      responses:
        "201":
          description: Connected
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/EcommerceConnection" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /workspaces/{workspaceId}/ecommerce/connections/{connectionId}:
    parameters:
      - { $ref: "#/components/parameters/WorkspaceId" }
      - { $ref: "#/components/parameters/EcommerceConnectionId" }
    patch:
      tags: [Integrations]
      operationId: updateEcommerceConnection
      summary: Rename a store, or pause and resume it
      description: >
        ATH-256 — requires `integrations.manage`. `disabled` is an admin
        pause, never an automatic state: the read is synchronous, so a
        store outage costs a workspace some grey panels and nothing else,
        and auto-disabling a good connection over it would need a human to
        undo. A connection in `needs_reauth` cannot be resumed this way —
        only reconnecting with a fresh credential fixes a rejected one, so
        pretending otherwise would just re-break it on the next read.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/EcommerceConnectionPatchInput" }
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/EcommerceConnection" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }
    delete:
      tags: [Integrations]
      operationId: disconnectEcommerceStore
      summary: Disconnect a store
      description: >
        ATH-256 — requires `integrations.manage`. The vault credential is
        destroyed, which is the part that is immediate and total:
        afterwards no request, replay or operator can call that merchant's
        store, because the token is gone rather than merely unreferenced.


        The connection row is stamped `revoked_at` rather than deleted
        (ATH-252's `RevokeOAuthInstall` discipline). There is nothing else
        to clean up — this integration stored no order data to purge,
        which is the fetch-on-demand decision paying out.
      responses:
        "204": { description: Disconnected }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /workspaces/{workspaceId}/ecommerce/customer-context:
    parameters: [{ $ref: "#/components/parameters/WorkspaceId" }]
    get:
      tags: [Integrations]
      operationId: getEcommerceCustomerContext
      summary: A customer's orders across connected stores (inbox sidebar)
      description: >
        ATH-256 — requires `inbox.view`, one tier below the management
        surface, because the reader is a support agent mid-conversation
        and this is a card in ATH-135's context sidebar. Fetched LIVE from
        each store on demand; nothing is synced or stored (see the
        EcommerceStoreContext schema on the `state` field and the
        data-residency reasoning).


        Keyed by `email` — the join key Cairn and a storefront reliably
        share — rather than by conversation id, so the integration does
        not reach into the conversations domain to resolve a contact.
        Every connected store is asked and every answer is returned,
        including a store that is throttled or down, so an agent never
        mistakes "we could not ask this store" for "this customer has no
        orders here".
      parameters:
        - name: email
          in: query
          required: true
          schema: { type: string, format: email, maxLength: 255 }
        - name: connection_id
          in: query
          required: false
          description: Scope the lookup to a single store instead of all of them.
          schema: { $ref: "#/components/schemas/Ulid" }
      responses:
        "200":
          description: One context per connected store
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/EcommerceStoreContext" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: Sanctum PAT or workspace API key

  parameters:
    WorkspaceId:
      name: workspaceId
      in: path
      required: true
      schema: { $ref: "#/components/schemas/Ulid" }
    CaptureId:
      name: captureId
      in: path
      required: true
      schema: { $ref: "#/components/schemas/Ulid" }
    CrmConnectionId:
      name: connectionId
      in: path
      required: true
      schema: { $ref: "#/components/schemas/Ulid" }
    EcommerceConnectionId:
      name: connectionId
      in: path
      required: true
      schema: { $ref: "#/components/schemas/Ulid" }
    ContentPushConnectionId:
      name: connectionId
      in: path
      required: true
      schema: { $ref: "#/components/schemas/Ulid" }
    Cursor:
      name: cursor
      in: query
      schema: { type: string }
    Limit:
      name: limit
      in: query
      schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      schema: { type: string, maxLength: 255 }

  responses:
    Unauthorized:
      description: Missing or invalid credentials
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Forbidden:
      description: Authenticated but not permitted (policy denial)
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    NotFound:
      description: Resource not found in this workspace
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    ValidationError:
      description: Validation failed
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Conflict:
      description: >
        The resource is no longer in a state that accepts this
        transition — e.g. an approval task someone else already decided.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }

  schemas:
    Ulid:
      type: string
      description: Crockford base32 ULID; Laravel emits lowercase, both cases accepted
      pattern: "^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$"
      examples: ["01j8me9fycv0q4t4c7wz8k2xt1"]

    Error:
      type: object
      additionalProperties: false
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
              description: Stable machine code (e.g. validation_failed, credits_exhausted, seat_limit_reached)
            message: { type: string }
            details:
              type: object
              additionalProperties: true
              description: e.g. per-field validation errors

    PaginatedList:
      type: object
      required: [meta]
      properties:
        meta:
          type: object
          required: [next_cursor]
          properties:
            next_cursor:
              type: [string, "null"]
              description: Pass as ?cursor= for the next page; null when exhausted

    PublicContact:
      type: object
      description: >
        ATH-250 — a contact as the public API exposes it. Deliberately
        NOT the portal's shape: the portal payload carries
        `suppression_reason`, which explains WHY a person stopped
        receiving mail (hard bounce, spam complaint, manual
        suppression) and is inference about someone's behaviour that
        the workspace's own staff should see and an integration should
        not. `unsubscribed` is here as a boolean, because respecting
        consent is exactly what a downstream CRM does need to know.
      required:
        [id, email, unsubscribed, created_at, name, phone, external_id, verified, attributes,
         last_seen_at]
      properties:
        id: { $ref: "#/components/schemas/Ulid" }
        email: { type: [string, "null"], format: email }
        name: { type: [string, "null"] }
        phone: { type: [string, "null"] }
        external_id: { type: [string, "null"] }
        verified: { type: boolean }
        attributes:
          type: object
          additionalProperties: true
        unsubscribed:
          type: boolean
          description: True when this contact must not receive campaign email.
        last_seen_at: { type: [string, "null"], format: date-time }
        created_at: { type: string, format: date-time }

    PublicConversation:
      type: object
      required:
        [id, channel, state, created_at, contact_id, kind, subject, reference, priority, rating,
         assignee_id, last_message_at, resolved_at]
      properties:
        id: { $ref: "#/components/schemas/Ulid" }
        contact_id: { type: [string, "null"] }
        channel: { type: string }
        state: { type: string, enum: [pending, open, snoozed, resolved] }
        kind: { type: string, enum: [conversation, ticket] }
        subject: { type: [string, "null"] }
        reference:
          type: [string, "null"]
          description: Human-facing ticket reference, when kind=ticket (ATH-214).
        priority: { type: [string, "null"] }
        rating: { type: [integer, "null"] }
        assignee_id: { type: [string, "null"] }
        last_message_at: { type: [string, "null"], format: date-time }
        resolved_at: { type: [string, "null"], format: date-time }
        created_at: { type: string, format: date-time }

    PublicMessage:
      type: object
      description: >
        ATH-250 — one message in a thread. `direction` is `in` or `out`
        only; internal notes never appear on this surface.
      required: [id, direction, author_type, created_at]
      properties:
        id: { $ref: "#/components/schemas/Ulid" }
        direction: { type: string, enum: [in, out] }
        author_type: { type: string, enum: [contact, visitor, user, ai_agent, workflow, campaign] }
        kind: { type: string, enum: [text, file, rich, event] }
        body: { type: [string, "null"] }
        created_at: { type: string, format: date-time }

    RetrievalSource:
      type: object
      additionalProperties: false
      description: Attribution for the document a retrieved chunk came from (ATH-258)
      required: [kind, id, title, url]
      properties:
        kind: { type: string, enum: [guide, article, page, crawl, upload] }
        id:
          type: [string, "null"]
          description: knowledge_sources row id — stable across re-chunking
        title: { type: [string, "null"] }
        url:
          type: [string, "null"]
          description: Step/page url for guide-step and crawled chunks; the source url otherwise

    RetrievalHit:
      type: object
      additionalProperties: false
      description: >
        One ranked retrieval result (ATH-258). `score` is the fused RRF
        rank signal (rank-based and tiny — compare, never threshold);
        `similarity` is the raw query-to-chunk cosine, the semantic
        signal grounding policies threshold on (ATH-162).
      required: [chunk_id, document_ref, text, audience, score, similarity, source]
      properties:
        chunk_id: { $ref: "#/components/schemas/Ulid" }
        document_ref:
          type: string
          description: e.g. `article:{id}`, `guide:{id}#step:{id}` — feed the id into the retrieval document reads
        text: { type: string }
        audience: { type: string, enum: [public, customers, internal] }
        score: { type: number }
        similarity: { type: number }
        source: { $ref: "#/components/schemas/RetrievalSource" }

    Role:
      type: string
      enum: [owner, admin, content_manager, creator, support_agent, analyst, viewer]

    User:
      type: object
      additionalProperties: false
      required: [id, name, email, avatar_url, two_factor_enabled, created_at]
      properties:
        id: { $ref: "#/components/schemas/Ulid" }
        name: { type: string }
        email: { type: string, format: email }
        avatar_url: { type: [string, "null"], format: uri }
        two_factor_enabled: { type: boolean }
        created_at: { type: string, format: date-time }

    Workspace:
      type: object
      required: [id, name, slug, plan, organization_id, trial_ends_at]
      properties:
        id: { $ref: "#/components/schemas/Ulid" }
        organization_id: { $ref: "#/components/schemas/Ulid" }
        name: { type: string }
        slug: { type: string }
        plan: { type: string, enum: [free, starter, growth, scale, enterprise] }
        trial_ends_at: { type: [string, "null"], format: date-time }
        settings: { type: object, additionalProperties: true }
        created_at: { type: string, format: date-time }

    Member:
      type: object
      required: [id, workspace_id, user, role, permissions, joined_at]
      properties:
        id: { $ref: "#/components/schemas/Ulid" }
        workspace_id: { $ref: "#/components/schemas/Ulid" }
        user: { $ref: "#/components/schemas/User" }
        role: { $ref: "#/components/schemas/Role" }
        permissions:
          type: [object, "null"]
          description: Per-member overrides
        joined_at: { type: string, format: date-time }

    Invitation:
      type: object
      additionalProperties: false
      required: [id, email, role, expires_at]
      properties:
        id: { $ref: "#/components/schemas/Ulid" }
        email: { type: string, format: email }
        role: { $ref: "#/components/schemas/Role" }
        expires_at: { type: string, format: date-time }

    NormalizedRect:
      type: object
      description: A rectangle in normalized 0..1 coordinates (ATH-081).
      required: [x, y, w, h]
      properties:
        x: { type: number, minimum: 0, maximum: 1 }
        y: { type: number, minimum: 0, maximum: 1 }
        w: { type: number, minimum: 0, maximum: 1 }
        h: { type: number, minimum: 0, maximum: 1 }

    SmartBlurSuggestion:
      type: object
      additionalProperties: false
      description: >
        PA-277 — an assisted Smart Blur region exposed for author review.
        OCR text and detected values are never serialized.
      required: [id, rect, category, status]
      properties:
        id: { $ref: "#/components/schemas/Ulid" }
        rect: { $ref: "#/components/schemas/NormalizedRect" }
        category: { $ref: "#/components/schemas/RedactionCategory" }
        status: { type: string, enum: [pending_review, applied, dismissed] }

    GuideOp:
      # ATH-017 — a DISCRIMINATED UNION: each op's `type` const fixes the
      # shape of its `payload`, so the generated TS narrows on `type` and
      # a mismatched payload (e.g. type: step.delete with a guide.update
      # body) is a compile error. This replaces `payload: {}` which
      # generated Record<string, never> and forced every editor op to cast
      # (ATH-007). Shapes mirror ApplyGuideOps' server validation exactly.
      oneOf:
        - type: object
          required: [type, payload]
          properties:
            type: { type: string, const: guide.update }
            payload:
              type: object
              properties:
                title: { type: string }
                description: { type: [string, "null"] }
                brand_profile_id: { $ref: "#/components/schemas/Ulid" }
        - type: object
          required: [type, payload]
          properties:
            type: { type: string, const: step.update_text }
            payload:
              type: object
              required: [step_id, text]
              properties:
                step_id: { $ref: "#/components/schemas/Ulid" }
                text: { type: string, description: sanitized HTML }
        - type: object
          required: [type, payload]
          properties:
            type: { type: string, const: step.add }
            payload:
              type: object
              properties:
                type: { type: string, enum: [manual, section] }
                position: { type: integer }
                text: { type: string }
        - type: object
          required: [type, payload]
          properties:
            type: { type: string, const: step.delete }
            payload:
              type: object
              required: [step_id]
              properties:
                step_id: { $ref: "#/components/schemas/Ulid" }
        - type: object
          required: [type, payload]
          properties:
            type: { type: string, const: step.reorder }
            payload:
              type: object
              required: [ordered_ids]
              properties:
                ordered_ids:
                  type: array
                  description: An exact permutation of the guide's step ids.
                  items: { $ref: "#/components/schemas/Ulid" }
        - type: object
          required: [type, payload]
          properties:
            type: { type: string, const: step.merge }
            payload:
              type: object
              required: [step_id, into_step_id]
              properties:
                step_id: { $ref: "#/components/schemas/Ulid" }
                into_step_id: { $ref: "#/components/schemas/Ulid" }
        - type: object
          required: [type, payload]
          properties:
            type: { type: string, const: step.split }
            payload:
              type: object
              required: [step_id, text_before, text_after]
              properties:
                step_id: { $ref: "#/components/schemas/Ulid" }
                text_before: { type: string }
                text_after: { type: string }
        - type: object
          required: [type, payload]
          properties:
            type: { type: string, const: step.set_callout }
            payload:
              type: object
              required: [step_id, callout]
              properties:
                step_id: { $ref: "#/components/schemas/Ulid" }
                callout:
                  type: [object, "null"]
                  required: [kind, text]
                  properties:
                    kind: { type: string, enum: [tip, alert, warning] }
                    text: { type: string }
        - type: object
          required: [type, payload]
          properties:
            type: { type: string, const: step.set_walkthrough }
            payload:
              type: object
              required: [step_id, walkthrough]
              properties:
                step_id: { $ref: "#/components/schemas/Ulid" }
                walkthrough:
                  # ATH-273 — mirrors GuideStep.walkthrough; null resets
                  # the step to the defaults (walkthroughable, written
                  # text, no placeholder).
                  type: [object, "null"]
                  properties:
                    enabled: { type: boolean, default: true }
                    alternate_text: { type: [string, "null"] }
                    input_placeholder: { type: [string, "null"] }
        - type: object
          required: [type, payload]
          properties:
            type: { type: string, const: step.set_screenshot_edits }
            payload:
              type: object
              required: [step_id, edits]
              properties:
                step_id: { $ref: "#/components/schemas/Ulid" }
                edits:
                  type: object
                  description: Coordinates are normalized 0..1.
                  properties:
                    # Nullable: ApplyGuideOps.normalizedRect returns null
                    # for a cleared crop, and the editor sends crop: null.
                    crop:
                      anyOf:
                        - { $ref: "#/components/schemas/NormalizedRect" }
                        - { type: "null" }
                    zoom: { type: boolean }
                    annotations:
                      type: array
                      items:
                        type: object
                        required: [type]
                        properties:
                          type: { type: string, enum: [rect, highlight, arrow, text] }
                          x: { type: number }
                          y: { type: number }
                          w: { type: number }
                          h: { type: number }
                          x2: { type: number }
                          y2: { type: number }
                          text: { type: string }
        - type: object
          required: [type, payload]
          properties:
            type: { type: string, const: step.set_redactions }
            payload:
              type: object
              required: [step_id, regions]
              properties:
                step_id: { $ref: "#/components/schemas/Ulid" }
                regions:
                  type: array
                  description: Manual blur; persists as redaction regions.
                  items: { $ref: "#/components/schemas/NormalizedRect" }
        - type: object
          required: [type, payload]
          properties:
            type: { type: string, const: step.replace_screenshot }
            payload:
              type: object
              required: [step_id, media_id]
              properties:
                step_id: { $ref: "#/components/schemas/Ulid" }
                media_id: { $ref: "#/components/schemas/Ulid" }
        - type: object
          required: [type, payload]
          properties:
            type: { type: string, const: step.restore_screenshot }
            payload:
              type: object
              required: [step_id]
              properties:
                step_id: { $ref: "#/components/schemas/Ulid" }
        - type: object
          required: [type, payload]
          properties:
            type: { type: string, const: guide.set_status }
            payload:
              type: object
              required: [status]
              properties:
                status: { type: string, enum: [draft, in_review, published, archived] }
        - type: object
          required: [type, payload]
          properties:
            type: { type: string, const: guide.save_version }
            payload:
              type: object
              properties:
                label: { type: string, maxLength: 120 }
        - type: object
          required: [type, payload]
          properties:
            type: { type: string, const: guide.restore_version }
            payload:
              type: object
              required: [number]
              properties:
                number: { type: integer }

    Export:
      type: object
      additionalProperties: false
      required: [id, subject_id, format, status, filename, error, url, expires_at, created_at]
      properties:
        id: { $ref: "#/components/schemas/Ulid" }
        subject_id: { $ref: "#/components/schemas/Ulid" }
        format: { type: string }
        status: { type: string, enum: [queued, processing, ready, failed] }
        filename: { type: [string, "null"] }
        error: { type: [string, "null"], description: Populated only when failed }
        url: { type: [string, "null"], format: uri, description: Signed download URL once ready }
        expires_at:
          type: [string, "null"]
          format: date-time
          description: >
            ATH-244 — when the artifact is reclaimed by the retention
            sweep. Set on large formats (video); null on the text
            formats, which are kept indefinitely.
        created_at: { type: [string, "null"], format: date-time }

    Collection:
      type: object
      additionalProperties: false
      required: [id, name, slug, access_level, position, parent_id]
      properties:
        id: { $ref: "#/components/schemas/Ulid" }
        name: { type: string }
        slug: { type: string }
        parent_id: { type: [string, "null"] }
        access_level: { type: string, enum: [public, unlisted, authenticated, internal] }
        position: { type: integer }
        children:
          type: array
          items: { type: object }

    CollectionInput:
      type: object
      properties:
        name: { type: string, maxLength: 120 }
        parent_id: { type: [string, "null"] }
        access_level: { type: string, enum: [public, unlisted, authenticated, internal] }
        position: { type: integer, minimum: 0 }

    Article:
      type: object
      additionalProperties: false
      required:
        [id, title, slug, access_level, status, collection_id, position, source_guide_id,
         diverged_from_guide, body_edited_since_generation, author_id, approved_by, published_at,
         updated_at]
      properties:
        id: { $ref: "#/components/schemas/Ulid" }
        title: { type: string }
        slug: { type: string }
        collection_id: { type: [string, "null"] }
        body: { type: [string, "null"], description: Sanitized HTML — omitted from list responses }
        access_level: { type: string, enum: [public, unlisted, authenticated, internal] }
        status: { type: string, enum: [draft, in_review, published, archived] }
        position: { type: integer }
        source_guide_id: { type: [string, "null"] }
        diverged_from_guide: { type: boolean, description: The source guide changed since generation (Doc 03 §4) }
        body_edited_since_generation:
          type: boolean
          description: The body was manually edited after generation/last sync — syncing requires force (ATH-091)
        author_id: { type: [string, "null"] }
        approved_by: { type: [string, "null"] }
        published_at: { type: [string, "null"], format: date-time }
        updated_at: { type: [string, "null"], format: date-time }

    ArticleInput:
      type: object
      properties:
        title: { type: string, maxLength: 255 }
        body: { type: [string, "null"] }
        collection_id: { type: [string, "null"] }
        access_level: { type: string, enum: [public, unlisted, authenticated, internal] }
        position: { type: integer, minimum: 0 }

    ArticleTranslation:
      description: >
        ATH-242 — one article in one locale (Doc 03 §5). A separate row
        with its own publish state rather than a column on the article,
        because a locale is publishable on its own: a half-finished
        German draft must stay invisible while the English source is
        live.
      type: object
      additionalProperties: false
      required:
        [id, article_id, locale, title, slug, status, url_path, stale, origin,
         machine_translated_at, translator_id, approved_by, published_at, updated_at]
      properties:
        id: { $ref: "#/components/schemas/Ulid" }
        article_id: { $ref: "#/components/schemas/Ulid" }
        locale:
          type: string
          description: Canonical BCP-47 tag — the form emitted into hreflang and html lang
        title: { type: string }
        slug: { type: string }
        url_path:
          type: string
          description: >
            The path this variant serves at. Non-default locales carry a
            path prefix (`/de/a/preise`); the workspace's default locale
            serves unprefixed at `/a/{slug}` so existing article URLs
            are unchanged.
        body: { type: [string, "null"], description: Sanitized HTML — omitted from list responses }
        status: { type: string, enum: [draft, in_review, published] }
        stale:
          type: boolean
          description: >
            The source article's body changed since this translation was
            last reconciled with it (Doc 03 §5 "source-change detection
            marks translations stale"). Computed by comparing a stored
            hash — no version history is assumed.
        origin:
          type: string
          enum: [human, machine]
          description: >
            ATH-225 — who wrote the text CURRENTLY in this row. `machine`
            means an AI translation nobody has edited since; `human`
            means a person authored or corrected it. Re-translation
            refuses (409) to overwrite a `human` row unless forced, so
            this field is what protects a reviewer's corrections from
            the next run. Any human write through the upsert endpoint
            sets it to `human`, including an edit of machine output —
            correcting a translation is what claiming it means.
        machine_translated_at:
          type: [string, "null"]
          format: date-time
          description: >
            When this row was last written by an AI translation run.
            Retained after a human edits the text (which flips `origin`
            to `human`), because "a person revised a machine draft" and
            "a person wrote this from scratch" are different provenance
            and only this field tells them apart.
        translator_id: { type: [string, "null"] }
        approved_by: { type: [string, "null"] }
        published_at: { type: [string, "null"], format: date-time }
        updated_at: { type: [string, "null"], format: date-time }

    ArticleTranslationRunResult:
      type: object
      additionalProperties: false
      required: [outcome, translation, credit_cost_cents, run_id, forced]
      properties:
        outcome:
          type: string
          enum: [translated]
          description: >
            Only a successful write returns this schema. Every refusal —
            human-edited (409), unusable structure or low confidence
            (422), no credits (402) — is an error status, never a 200
            carrying a failure a caller might mistake for success.
        translation: { $ref: "#/components/schemas/ArticleTranslation" }
        run_id:
          allOf: [{ $ref: "#/components/schemas/Ulid" }]
          description: The append-only provenance row this attempt wrote.
        credit_cost_cents:
          type: integer
          description: >
            Charged per 1k characters of source text (Doc 05 §9, 0.4¢),
            rounded up — a 200-character article still costs a call.
        forced:
          type: boolean
          description: >
            True when this run overwrote a human-edited translation
            because the caller passed force. Recorded so a reviewer whose
            corrections were replaced can find out that it happened.

    ArticleTranslationInput:
      type: object
      required: [title]
      properties:
        title: { type: string, maxLength: 255 }
        body: { type: [string, "null"] }
        slug:
          type: string
          maxLength: 160
          description: >
            Optional — derived from the translated title when omitted.
            Unique per workspace PER LOCALE, so `/a/pricing` and
            `/de/a/pricing` may coexist.

    Page:
      type: object
      additionalProperties: false
      required:
        [id, title, slug, access_level, status, url_path, author_id, approved_by, published_at,
         updated_at]
      properties:
        id: { $ref: "#/components/schemas/Ulid" }
        title: { type: string }
        slug: { type: string }
        url_path:
          type: string
          description: >
            The path this page serves at on the help centre — always the
            slug at the site ROOT (`/{slug}`). Articles live under `/a/`
            and collections under `/c/`, so the two namespaces cannot
            collide; the reserved-slug list is what keeps a page off the
            help centre's own paths.
        access_level: { type: string, enum: [public, unlisted, authenticated, internal] }
        status: { type: string, enum: [draft, in_review, published, archived] }
        blocks:
          type: array
          description: Ordered typed blocks — omitted from list responses
          items: { $ref: "#/components/schemas/PageBlock" }
        author_id: { type: [string, "null"] }
        approved_by: { type: [string, "null"] }
        published_at: { type: [string, "null"], format: date-time }
        updated_at: { type: [string, "null"], format: date-time }

    PageInput:
      type: object
      properties:
        title: { type: string, maxLength: 255 }
        slug:
          type: string
          maxLength: 160
          description: >
            Optional — derived from the title when omitted. Lowercase
            alphanumerics and hyphens only. Unique per workspace and
            refused when it matches a reserved help-centre segment.
        access_level: { type: string, enum: [public, unlisted, authenticated, internal] }
        blocks:
          type: array
          maxItems: 200
          items: { $ref: "#/components/schemas/PageBlock" }

    PageBlock:
      description: >
        ATH-240 — one typed block of a page body (Doc 03 §3). The server
        validates each block against its variant on every write and
        rejects unknown types with 422, so the stored JSON is never
        free-form. Rich text is sanitized with the same allowlist as
        article bodies.
      oneOf:
        - { $ref: "#/components/schemas/PageBlockRichText" }
        - { $ref: "#/components/schemas/PageBlockHeading" }
        - { $ref: "#/components/schemas/PageBlockGuide" }
        - { $ref: "#/components/schemas/PageBlockImage" }
        - { $ref: "#/components/schemas/PageBlockVideo" }
        - { $ref: "#/components/schemas/PageBlockCallout" }
        - { $ref: "#/components/schemas/PageBlockTable" }
        - { $ref: "#/components/schemas/PageBlockButton" }
      discriminator:
        propertyName: type
        mapping:
          rich_text: "#/components/schemas/PageBlockRichText"
          heading: "#/components/schemas/PageBlockHeading"
          guide: "#/components/schemas/PageBlockGuide"
          image: "#/components/schemas/PageBlockImage"
          video: "#/components/schemas/PageBlockVideo"
          callout: "#/components/schemas/PageBlockCallout"
          table: "#/components/schemas/PageBlockTable"
          button: "#/components/schemas/PageBlockButton"

    PageBlockRichText:
      type: object
      required: [type, html]
      properties:
        type: { type: string, enum: [rich_text] }
        html: { type: string, maxLength: 100000, description: Sanitized server-side }

    PageBlockHeading:
      type: object
      required: [type, level, text]
      properties:
        type: { type: string, enum: [heading] }
        level: { type: integer, enum: [2, 3, 4], description: Feeds the table of contents }
        text: { type: string, maxLength: 255 }

    PageBlockGuide:
      type: object
      required: [type, guide_id]
      properties:
        type: { type: string, enum: [guide] }
        guide_id:
          allOf: [{ $ref: "#/components/schemas/Ulid" }]
          description: >
            A LIVE reference (Doc 03 §3) — the block stores the id only
            and renders the guide as it stands, so updating the guide
            updates every page embedding it. Must name a guide in the
            same workspace.

    PageBlockImage:
      type: object
      required: [type, media_id]
      properties:
        type: { type: string, enum: [image] }
        media_id: { $ref: "#/components/schemas/Ulid" }
        alt: { type: [string, "null"], maxLength: 255 }
        caption: { type: [string, "null"], maxLength: 500 }

    PageBlockVideo:
      type: object
      required: [type, url]
      properties:
        type: { type: string, enum: [video] }
        url:
          type: string
          format: uri
          maxLength: 2048
          description: >
            An https embed URL. Restricted to an allowlist of embed hosts
            server-side so a page cannot smuggle an arbitrary iframe onto
            the help centre.
        caption: { type: [string, "null"], maxLength: 500 }

    PageBlockCallout:
      type: object
      required: [type, variant, html]
      properties:
        type: { type: string, enum: [callout] }
        variant: { type: string, enum: [info, tip, warning, danger] }
        html: { type: string, maxLength: 20000, description: Sanitized server-side }

    PageBlockTable:
      type: object
      required: [type, rows]
      properties:
        type: { type: string, enum: [table] }
        header: { type: boolean, description: Render the first row as a header row }
        rows:
          type: array
          maxItems: 200
          items:
            type: array
            maxItems: 20
            items: { type: string, maxLength: 2000 }

    PageBlockButton:
      type: object
      required: [type, label, url]
      properties:
        type: { type: string, enum: [button] }
        label: { type: string, maxLength: 120 }
        url: { type: string, maxLength: 2048, description: "http(s) or a site-relative path" }
        style: { type: string, enum: [primary, secondary] }

    ContentVerification:
      type: object
      additionalProperties: false
      description: >
        ATH-241 — the review schedule for one piece of published
        knowledge (Doc 03 §2.1). `status` is a CALENDAR state: it says
        whether a human has re-read the content recently enough, and
        nothing about whether the content is still correct. Content that
        reality has moved out from under is a separate axis, carried by
        ATH-284's `drifted_at` on the knowledge source.
      required:
        [id, content_type, content_id, interval_days, status, due_at, owner_id, verified_by,
         verified_at, created_at, updated_at]
      properties:
        id: { $ref: "#/components/schemas/Ulid" }
        content_type: { type: string, enum: [article, page, guide] }
        content_id: { $ref: "#/components/schemas/Ulid" }
        owner_id:
          type: [string, "null"]
          description: >
            The workspace member accountable for re-reading this. Null
            means the cadence still runs and the review still comes due —
            it just has nobody named to chase. PA-253 — this is
            forward-looking and is nulled automatically when the named
            owner leaves the workspace, so it never points at a non-member;
            such content is findable with the `unowned` filter. (Contrast
            `verified_by`, which records a past fact and is never rewritten.)
        interval_days:
          type: integer
          description: Days between reviews; `due_at` is the last verification plus this.
        status:
          type: string
          enum: [verified, due, expired]
          description: >
            `verified` — reviewed within the interval. `due` — the review
            date has passed. `expired` — the grace period after the due
            date has also passed. Transitions into `due` and `expired`
            are made only by the scheduled sweep.
        verified_by: { type: [string, "null"] }
        verified_at: { type: [string, "null"], format: date-time }
        due_at: { type: string, format: date-time }
        records:
          type: array
          description: >
            The append-only re-verification trail — who attested to this
            content and when. Newest first. Detail responses only.
          items: { $ref: "#/components/schemas/ContentVerificationRecord" }
        created_at: { type: [string, "null"], format: date-time }
        updated_at: { type: [string, "null"], format: date-time }

    ContentVerificationInput:
      type: object
      required: [interval_days]
      properties:
        interval_days: { type: integer, minimum: 1, maximum: 3650 }
        owner_id:
          type: [string, "null"]
          description: Must be a member of this workspace.

    ContentVerificationRecord:
      type: object
      additionalProperties: false
      description: >
        ATH-241 — one human attestation, never updated and never
        deleted. This is the claim the help centre is making internally:
        "this was verified by X on date Y".
      required: [id, verified_at, interval_days, verified_by, previous_status, note]
      properties:
        id: { $ref: "#/components/schemas/Ulid" }
        verified_by:
          type: [string, "null"]
          description: Null only if the user was deleted after attesting.
        verified_at: { type: string, format: date-time }
        interval_days:
          type: integer
          description: The cadence in force at the time — kept so history reads correctly after the cadence changes.
        previous_status: { type: string, enum: [verified, due, expired] }
        note: { type: [string, "null"] }

    ContentVersionSummary:
      type: object
      additionalProperties: false
      description: >
        ATH-024 — one row of the version list (Doc 03 §2.1): enough for a
        human to tell two versions apart and pick one, without loading
        either payload. The type-specific fields differ by content type —
        a page carries `block_count`, an article `body_length`.
      required: [number, created_at, label, created_by, title, slug, status, access_level]
      properties:
        number:
          type: integer
          description: >
            Monotonic per content item and never reused. Retention prunes
            the oldest rows, so the sequence can have gaps at the bottom;
            a pruned number 404s rather than resolving to another
            snapshot.
        label:
          type: [string, "null"]
          description: '`Published` for a snapshot taken on publish, `Pre-restore of vN` for the state a restore overwrote.'
        created_by: { type: [string, "null"] }
        created_at: { type: string, format: date-time }
        title: { type: [string, "null"] }
        slug: { type: [string, "null"] }
        status:
          type: [string, "null"]
          description: The content's workflow state WHEN CAPTURED. Recorded for the trail; a restore never reapplies it.
        access_level:
          type: [string, "null"]
          description: The content's audience WHEN CAPTURED. Recorded for the trail; a restore never reapplies it.
        block_count:
          type: integer
          description: Pages only.
        body_length:
          type: integer
          description: Articles only.

    ContentVersion:
      type: object
      additionalProperties: false
      description: >
        ATH-024 — one snapshot with its stored payload (Doc 03 §2.1).


        PA-324 — the detail response is the LIST ROW PLUS the payload:
        `ContentVersionController::payload()` spreads the very same
        `ContentVersions::entry()` the list uses, then adds `content_type`,
        `content_id` and `snapshot`. The descriptive fields below were on the
        wire from the beginning and simply undocumented here, which left the
        detail endpoint described as poorer than the list of it.


        They are spelled out rather than composed from
        `ContentVersionSummary` because `additionalProperties: false` does
        NOT compose through `allOf` — the keyword only ever sees its own
        schema object, so a branch inheriting these properties by reference
        would reject every one of them as undeclared. The duplication is
        deliberate and load-bearing; keep the two lists in step.
      required: [number, created_at, snapshot, label, created_by, content_type, content_id,
                 title, slug, status, access_level]
      properties:
        number: { type: integer }
        label: { type: [string, "null"] }
        created_by: { type: [string, "null"] }
        created_at: { type: string, format: date-time }
        content_type: { type: string, enum: [article, page] }
        content_id: { $ref: "#/components/schemas/Ulid" }
        title: { type: [string, "null"] }
        slug: { type: [string, "null"] }
        status:
          type: [string, "null"]
          description: The content's workflow state WHEN CAPTURED. Recorded for the trail; a restore never reapplies it.
        access_level:
          type: [string, "null"]
          description: The content's audience WHEN CAPTURED. Recorded for the trail; a restore never reapplies it.
        block_count:
          type: integer
          description: Pages only.
        body_length:
          type: integer
          description: Articles only.
        snapshot:
          type: object
          additionalProperties: true
          description: >
            The captured content. An article carries `title`, `slug`,
            `body`, `collection_id`; a page carries `title`, `slug`,
            `blocks`. Both also carry `status` and `access_level`, which
            are recorded for the trail and never reapplied by a restore.

    CrawlConfig:
      type: object
      additionalProperties: false
      required: [id, root_url, status]
      properties:
        id: { $ref: "#/components/schemas/Ulid" }
        root_url: { type: string, format: uri }
        status: { type: string, enum: [pending, crawling, done, error] }
        max_pages: { type: integer }
        max_depth: { type: integer }
        pages_crawled: { type: integer }
        pages_skipped: { type: integer }
        last_crawled_at: { type: [string, "null"], format: date-time }
        error: { type: [string, "null"] }
        created_at: { type: [string, "null"], format: date-time }

    AnalyticsBreakdown:
      type: array
      description: One metric split by a dimension, highest first.
      items:
        type: object
        required: [key, value]
        properties:
          key: { type: string }
          value: { type: number }

    AnalyticsDashboard:
      type: object
      description: >
        The shape both dashboards share. Each endpoint adds its own
        breakdowns on top (support → by_channel; content → search
        terms), so the two are NOT interchangeable — see the operations.
      required: [from, to, metrics]
      properties:
        from: { type: string, format: date-time }
        to: { type: string, format: date-time }
        metrics:
          type: array
          items:
            type: object
            required: [metric, label, total, series]
            properties:
              metric: { type: string }
              label: { type: string }
              total: { type: number }
              series:
                type: array
                items:
                  type: object
                  required: [period, value]
                  properties:
                    period: { type: string }
                    value: { type: number }

    MetricDefinition:
      type: object
      additionalProperties: false
      description: >
        ATH-259 — one entry in the metric whitelist a custom dashboard may
        reference (Doc 06 §3.4). Every registered metric is ADDITIVE (a
        count or a money sum), which is what lets the same registration
        serve both a headline stat and a daily trend. Rates are absent by
        design: a ratio does not sum across days, so the AI dashboard
        computes those from two additive metrics instead.
      required: [id, label, category, unit]
      properties:
        id: { type: string, description: The stable id a dashboard stores., examples: ["ai_answered"] }
        label: { type: string, examples: ["AI answered"] }
        category:
          type: string
          enum: [support, content, ai, guidance]
          description: >
            PA-324 — `guidance` (the PA-229 nudge funnel: shown, engaged,
            walkthrough started, helped, conversation started) was registered
            in `MetricRegistry` without being added here, so five live,
            dashboard-referenceable metrics sat outside their own enum and the
            generated client typed `category` as a three-member union.
            A metric added to the registry belongs in this list.
        unit:
          type: string
          enum: [count, micros, cents, minutes]
          description: Formatting hint only — never a second source of truth for the number.

    DashboardWidget:
      type: object
      description: A reference to one registered metric and how to draw it.
      required: [metric, viz]
      properties:
        metric: { type: string, description: A registered metric id (see listDashboardMetrics)., examples: ["ai_answered"] }
        viz: { type: string, enum: [stat, series] }

    DashboardInput:
      type: object
      description: ATH-259 — create a saved dashboard from registered metric ids.
      required: [name, widgets]
      properties:
        name: { type: string, maxLength: 120 }
        widgets:
          type: array
          minItems: 1
          maxItems: 24
          items: { $ref: "#/components/schemas/DashboardWidget" }

    DashboardUpdate:
      type: object
      description: ATH-259 — PATCH a saved dashboard; both fields optional.
      properties:
        name: { type: string, maxLength: 120 }
        widgets:
          type: array
          minItems: 1
          maxItems: 24
          items: { $ref: "#/components/schemas/DashboardWidget" }

    Dashboard:
      type: object
      additionalProperties: false
      description: >
        ATH-259 — a saved custom dashboard as STORED (Doc 06 §3.4): a name
        plus the registered metric ids it references. Never a query.
      required: [id, name, widgets, created_by, created_at, updated_at]
      properties:
        id: { $ref: "#/components/schemas/Ulid" }
        name: { type: string }
        widgets:
          type: array
          items: { $ref: "#/components/schemas/DashboardWidget" }
        created_by:
          oneOf: [{ $ref: "#/components/schemas/Ulid" }, { type: "null" }]
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    ResolvedDashboardWidget:
      type: object
      additionalProperties: false
      description: >
        One widget with its numbers resolved over the window. `total` is
        null and `available` false when the referenced metric is no longer
        registered — a retired metric must read as gone, not as zero.
      required: [metric, label, unit, viz, available, series, total]
      properties:
        metric: { type: string }
        label: { type: string }
        unit: { type: string, enum: [count, micros, cents, minutes] }
        viz: { type: string, enum: [stat, series] }
        available: { type: boolean }
        total:
          oneOf: [{ type: number }, { type: "null" }]
        series:
          type: array
          description: Day buckets; empty for `stat` widgets and for unavailable metrics.
          items:
            type: object
            required: [period, value]
            properties:
              period: { type: string }
              value: { type: number }

    ResolvedDashboard:
      type: object
      additionalProperties: false
      description: ATH-259 — a saved dashboard evaluated over a date range.
      required: [id, name, from, to, widgets]
      properties:
        id: { $ref: "#/components/schemas/Ulid" }
        name: { type: string }
        from: { type: string, format: date-time }
        to: { type: string, format: date-time }
        widgets:
          type: array
          items: { $ref: "#/components/schemas/ResolvedDashboardWidget" }

    AiDashboardRates:
      type: object
      additionalProperties: false
      description: >
        Ratios over the window, each in [0,1] and 0 when there were no
        runs to measure. Computed from the additive run counts rather than
        stored, because a ratio does not sum across days.
      required: [answer_rate, escalation_rate, automated_resolution_rate]
      properties:
        answer_rate: { type: number, description: answered ÷ runs. }
        escalation_rate: { type: number, description: escalated ÷ runs. }
        automated_resolution_rate:
          type: number
          description: (answered + actions executed) ÷ runs — the share of turns the agent closed without a human.

    AiDeflection:
      type: object
      additionalProperties: false
      description: >
        ATH-285's deflection ROI, read through the Metrics port. These
        rollups are only produced where `feature.knowledge.self-healing`
        runs, so `available` reports whether the pipeline is on — a zero
        from a pipeline nobody enabled is not the same claim as "no
        deflections", and this endpoint refuses to conflate them.
      required: [available, deflections, escalations, deflection_rate, value_cents, value_per_contact_cents]
      properties:
        available: { type: boolean, description: Whether the deflection-ROI pipeline runs for this workspace. }
        deflections: { type: number, description: Avoided human contacts (agent + walkthrough). }
        escalations: { type: number, description: Conversations that reached a human — the rate denominator. }
        deflection_rate:
          type: number
          description: >
            agent deflections ÷ (agent deflections + escalations).
            Walkthroughs are excluded from the rate because they never had
            a chance to escalate; including them would flatter it.
        value_cents: { type: integer }
        value_per_contact_cents: { type: integer, description: The configurable cost-per-contact assumption behind value_cents. }

    AiDashboard:
      type: object
      additionalProperties: false
      description: >
        ATH-259 — the AI dashboard (Doc 06 §3.3). `metrics` carries the
        additive run/spend counts (each resolved through the same metric
        registry a custom dashboard uses), `rates` the ratios derived from
        them, `escalation_reasons` the split of WHY the agent handed off —
        read from the real agent_runs.escalation_reason column, not the
        null-dims event rollup — and `deflection` the ATH-285 ROI block.
      required: [from, to, metrics, rates, escalation_reasons, deflection]
      properties:
        from: { type: string, format: date-time }
        to: { type: string, format: date-time }
        metrics:
          type: array
          items:
            type: object
            required: [metric, label, total, series]
            properties:
              metric: { type: string }
              label: { type: string }
              total: { type: number }
              series:
                type: array
                items:
                  type: object
                  required: [period, value]
                  properties:
                    period: { type: string }
                    value: { type: number }
        rates: { $ref: "#/components/schemas/AiDashboardRates" }
        escalation_reasons: { $ref: "#/components/schemas/AnalyticsBreakdown" }
        deflection: { $ref: "#/components/schemas/AiDeflection" }

    NudgeTuningRates:
      type: object
      additionalProperties: false
      description: >
        PA-229 — funnel ratios, each over nudges SHOWN in the window. A ratio
        does not sum across days, so these are computed from the additive
        counts rather than registered as metrics.
      required: [engagement_rate, help_rate, avoidance_rate]
      properties:
        engagement_rate: { type: number, description: "clicked ÷ shown — how often a shown nudge was acted on." }
        help_rate: { type: number, description: "helped (walkthrough completed) ÷ shown." }
        avoidance_rate: { type: number, description: "conversations_avoided ÷ shown." }

    NudgeTuningDashboard:
      type: object
      additionalProperties: false
      description: >
        PA-229 (Doc 07 §4) — the nudge tuning dashboard. `metrics` carries the
        additive funnel counts, each resolved through the same metric registry
        a custom dashboard uses (`nudges_shown`, `nudges_clicked`,
        `nudges_walkthrough_started`, `nudges_helped`,
        `nudges_conversation_started`); `rates` the ratios derived from them;
        `conversations_avoided` is `nudges_shown − nudges_conversation_started`
        (the deflection proxy). `available` is whether the nudge pipeline runs
        for this workspace.
      required: [from, to, available, metrics, rates, conversations_avoided]
      properties:
        from: { type: string, format: date-time }
        to: { type: string, format: date-time }
        available: { type: boolean, description: Whether feature.guidance.nudges is active for this workspace. }
        conversations_avoided: { type: number, description: "nudges_shown − nudges_conversation_started; a shown nudge that did not lead to a support conversation." }
        metrics:
          type: array
          items:
            type: object
            required: [metric, label, total, series]
            properties:
              metric: { type: string }
              label: { type: string }
              total: { type: number }
              series:
                type: array
                items:
                  type: object
                  required: [period, value]
                  properties:
                    period: { type: string }
                    value: { type: number }
        rates: { $ref: "#/components/schemas/NudgeTuningRates" }

    DeflectionRoiArticle:
      type: object
      additionalProperties: false
      description: >
        PA-226 (Doc 07 §3) — one article's deflection ROI over the window.
        `deflections` is the credit the ATH-285 rollup attributed to this
        article's knowledge (fractional when an answer was grounded by
        several sources); `question_cluster_volume` is the summed
        `gap_count` of the gap-cluster theme(s) this article was drafted to
        cover (Doc 07 §3's "question-cluster volume delta"), 0 for an
        article not linked to a mined/gap cluster.
      required: [article_id, title, deflections, value_cents, agent_minutes_saved, question_cluster_volume, covered_cluster_count]
      properties:
        article_id: { $ref: "#/components/schemas/Ulid" }
        title: { type: string }
        deflections: { type: number, description: Credited deflected contacts attributed to this article. }
        value_cents: { type: integer, description: "`deflections` × `value_per_contact_cents`, rounded." }
        agent_minutes_saved: { type: number, description: "`deflections` × `handle_minutes_per_contact`." }
        question_cluster_volume: { type: integer, description: Summed gap_count of the question-cluster theme(s) this article covers. }
        covered_cluster_count: { type: integer, description: How many distinct gap-cluster themes this article is linked to. }

    DeflectionRoiIntent:
      type: object
      additionalProperties: false
      description: >
        PA-226 (Doc 07 §3) — one deflected question theme ("intent"),
        ranked by deflected-conversation volume. `revenue_linked` is always
        false: the platform has no per-conversation revenue signal yet, so
        this is the honest volume proxy for a "revenue-touching intent",
        not a revenue attribution. Wiring a real revenue signal (CRM deal
        value / plan tier on the deflected conversation) is a documented
        seam.
      required: [intent, deflections, value_cents, revenue_linked]
      properties:
        intent: { type: string, description: "The theme label — a covered gap cluster's canonical question, else the knowledge source's title." }
        deflections: { type: number }
        value_cents: { type: integer }
        revenue_linked: { type: boolean, description: Always false until a per-conversation revenue signal exists (seam). }

    DeflectionRoiDashboard:
      type: object
      additionalProperties: false
      description: >
        PA-226 (ATH-304), Doc 07 §3 — the deflection-ROI dashboard, read
        from the ATH-285 `metric_rollups` buckets through the ATH-141
        Metrics port. `available` is whether the ROI pipeline
        (`feature.knowledge.self-healing`) is producing rollups for this
        workspace — false means the numbers are structurally empty, not
        "nothing was deflected". Both multipliers the money/time math uses
        (`value_per_contact_cents`, `handle_minutes_per_contact`) are
        returned so every figure is auditable rather than magic.
      required:
        [from, to, available, revenue_signal_available, value_per_contact_cents,
         handle_minutes_per_contact, deflections_total, agent_minutes_saved,
         value_cents, per_article, top_intents]
      properties:
        from: { type: string, format: date-time, description: Window start (inclusive) — midnight of the first rolled-up day. }
        to: { type: string, format: date-time, description: Window end (exclusive). }
        available: { type: boolean, description: Whether the deflection-ROI rollup pipeline runs for this workspace. }
        revenue_signal_available: { type: boolean, description: Whether a per-conversation revenue signal exists to attribute intents by — currently always false (seam). }
        value_per_contact_cents: { type: integer, description: "The cost-per-human-contact assumption, surfaced so `value_cents` is auditable." }
        handle_minutes_per_contact: { type: number, description: "The average agent handle time per avoided contact, surfaced so `agent_minutes_saved` is auditable." }
        deflections_total: { type: number, description: Avoided human contacts (agent + walkthrough) over the window. }
        agent_minutes_saved: { type: number, description: "`deflections_total` × `handle_minutes_per_contact`." }
        value_cents: { type: integer, description: "`deflections_total` × `value_per_contact_cents`." }
        per_article:
          type: array
          description: Articles ranked by credited deflections, highest first.
          items: { $ref: "#/components/schemas/DeflectionRoiArticle" }
        top_intents:
          type: array
          description: Deflected question themes ranked by volume, highest first.
          items: { $ref: "#/components/schemas/DeflectionRoiIntent" }

    KnowledgeHealthSummary:
      type: object
      description: ATH-281 — Knowledge Health triage tiles (Doc 07 §1).
      required: [cluster_count, gap_count, drifted_guide_count]
      properties:
        cluster_count: { type: integer, description: Open (undispositioned) gap clusters in the workspace. }
        gap_count: { type: integer, description: Open knowledge gaps (agent misses) in the workspace. }
        drifted_guide_count: { type: integer, description: Guides currently paused for selector drift (ATH-284). }

    KnowledgeHealthGapSample:
      type: object
      additionalProperties: false
      description: A representative member gap of a cluster (ATH-281).
      required: [id, question]
      properties:
        id: { $ref: "#/components/schemas/Ulid" }
        question: { type: string }

    KnowledgeHealthCluster:
      type: object
      description: >
        ATH-281 — one ranked gap-cluster theme for the Knowledge Health
        triage queue (Doc 07 §1). `canonical_question` is the cluster's
        representative question (an LLM-suggested title is a later
        refinement); `samples` are a few member gaps for the expandable
        drill-down.
      required: [id, canonical_question, gap_count, last_seen_at, disposition, samples, est_deflection_value_cents, draft, capture]
      properties:
        id: { $ref: "#/components/schemas/Ulid" }
        canonical_question: { type: string }
        gap_count: { type: integer }
        capture:
          oneOf:
            - { $ref: "#/components/schemas/KnowledgeHealthClusterCapture" }
            - type: "null"
          description: >
            ATH-282 — the capture request raised for this theme, or null
            when none has been. Sits beside `draft` on the triage row: the
            two are the doc's alternative dispositions, one for themes that
            can be written from what is already known and one for themes
            that need somebody to record the flow.
        draft:
          oneOf:
            - { $ref: "#/components/schemas/KnowledgeHealthClusterDraft" }
            - type: "null"
          description: >
            ATH-283 — the AI draft requested for this theme, or null when
            none has been. Present on the triage row so the screen can show
            "drafting…" / "draft ready" instead of offering to spend credits
            on a cluster that already has one.
        est_deflection_value_cents:
          type: integer
          description: >
            ATH-285 — what closing this theme is modelled to be worth:
            `gap_count` × the workspace's cost-per-human-contact
            assumption (`roi.value_per_contact_cents`). Materialized by
            the ROI rollup; computed on read for a cluster the rollup has
            not visited yet. This is the "deflection value" half of Doc
            07 §1's "rank = frequency × deflection value".
        last_seen_at: { type: [string, "null"], format: date-time }
        disposition:
          type: [string, "null"]
          description: One of reviewed|dismissed once triaged; null while the cluster is open in the queue.
        samples:
          type: array
          items: { $ref: "#/components/schemas/KnowledgeHealthGapSample" }

    KnowledgeHealthClusterDraft:
      type: object
      additionalProperties: false
      description: >
        ATH-283 — the state of the AI draft for a gap cluster (Doc 07 §1
        disposition "a"). `pending` means the drafting job is queued or
        running; `created` means the article exists and is waiting in the
        ATH-090 review queue; `failed` means the job gave up (the model
        returned nothing usable, or retrieval found no knowledge to ground
        in) and the cluster may be drafted again. The state doubles as the
        duplicate-draft guard — a cluster is only draftable while this is
        absent or `failed`.
      required: [status, article_id, requested_at]
      properties:
        status: { type: string, enum: [pending, created, failed] }
        article_id:
          oneOf:
            - { $ref: "#/components/schemas/Ulid" }
            - type: "null"
          description: The drafted article, once the job has written it. Null while pending or after a failure.
        requested_at: { type: [string, "null"], format: date-time, description: When drafting was requested for this cluster. }

    KnowledgeHealthClusterCapture:
      type: object
      description: >
        ATH-282 — the capture request raised for a gap cluster (Doc 07 §1
        disposition "b"), as the triage row needs it. The state doubles as
        the duplicate-request guard: a theme is only requestable while this
        is absent or `dismissed`. `completed` means somebody recorded the
        flow and `guide_id` is the guide that answers the theme — the
        closed loop.
      required: [request_id, status, assignee_id, guide_id, deep_link, requested_at]
      properties:
        request_id: { $ref: "#/components/schemas/Ulid" }
        status: { type: string, enum: [open, in_progress, completed, dismissed] }
        assignee_id:
          oneOf:
            - { $ref: "#/components/schemas/Ulid" }
            - type: "null"
        guide_id:
          description: The recorded guide, once one has been captured for this request.
          oneOf:
            - { $ref: "#/components/schemas/Ulid" }
            - type: "null"
        deep_link:
          type: string
          format: uri
          description: >
            Portal URL that opens the browser extension primed to record
            for this request. See `CaptureRequest.deep_link`.
        requested_at: { type: [string, "null"], format: date-time }

    CaptureRequest:
      type: object
      description: >
        ATH-282 — a documentation work order asking a human to RECORD a
        flow (Doc 07 §1 disposition "b"). Raised from a knowledge-gap
        cluster the AI could not answer and existing knowledge could not
        cover, assigned to a suggested subject-matter expert, and closed by
        the guide that gets captured for it.
      required:
        [id, cluster_id, title, status, assignee_id, requested_by, capture_session_id, guide_id,
         deep_link, created_at, completed_at, canonical_question, assignee_name]
      properties:
        id: { $ref: "#/components/schemas/Ulid" }
        cluster_id:
          description: The gap cluster this work order answers.
          oneOf:
            - { $ref: "#/components/schemas/Ulid" }
            - type: "null"
        title: { type: string, description: What to record — defaults to the cluster's canonical question. }
        canonical_question:
          type: [string, "null"]
          description: The cluster's representative question, for context on the recording screen.
        status:
          type: string
          enum: [open, in_progress, completed, dismissed]
          description: >
            `open` = raised and waiting; `in_progress` = a recording session
            has started against it; `completed` = a guide was captured and
            linked back; `dismissed` = closed without a recording, which
            releases the cluster to be requested again.
        assignee_id:
          oneOf:
            - { $ref: "#/components/schemas/Ulid" }
            - type: "null"
        assignee_name: { type: [string, "null"] }
        requested_by:
          oneOf:
            - { $ref: "#/components/schemas/Ulid" }
            - type: "null"
        capture_session_id:
          description: The recording session started for this request, once one has.
          oneOf:
            - { $ref: "#/components/schemas/Ulid" }
            - type: "null"
        guide_id:
          description: The captured guide that closed this request.
          oneOf:
            - { $ref: "#/components/schemas/Ulid" }
            - type: "null"
        deep_link:
          type: string
          format: uri
          description: >
            The URL that sends an assignee straight into recording:
            `{portal}/w/{workspaceId}/capture?cairn_capture_request={id}`.
            The portal page it lands on explains what to record and works
            on its own; the `cairn_capture_request` query parameter is the
            contract the browser extension's content script recognises (the
            same shape as Doc 07 §2's `?cairn_walkthrough=`). The
            extension honours it only on its own configured portal origin,
            and only after resolving the id through `getCaptureRequest`
            with the signed-in user's token — so a link pasted on a hostile
            page, or one naming another tenant's request, starts nothing.
        created_at: { type: [string, "null"], format: date-time }
        completed_at: { type: [string, "null"], format: date-time }

    KnowledgeHealthDriftedGuide:
      type: object
      description: >
        ATH-281 — a guide paused as an agent answer source for selector
        drift (ATH-284), with its step-health context (Doc 07 §1). Resume
        is automatic when the steps recover, so this is read-only here;
        `guide_id` deep-links to the guide editor.
      required: [guide_id, source_id, title, drifted_at, failing_step_count, last_ok_at]
      properties:
        guide_id: { $ref: "#/components/schemas/Ulid" }
        source_id: { $ref: "#/components/schemas/Ulid" }
        title: { type: string }
        drifted_at: { type: string, format: date-time }
        failing_step_count: { type: integer, description: Steps whose consecutive-failure streak has reached the drift threshold (ATH-275). }
        last_ok_at: { type: [string, "null"], format: date-time, description: Most recent time any of the guide's steps last resolved cleanly. }

    KnowledgeHealthRoiSource:
      type: object
      additionalProperties: false
      description: >
        ATH-285 — one knowledge source credited with deflections over the
        window (Doc 07 §1 "shown per article"). Credit for an AI answer is
        split evenly across the distinct sources that grounded it, so the
        per-source deflections sum to the agent deflection total rather
        than inflating it; a completed walkthrough credits its guide in
        full. Ordered by credited value, highest first.
      required: [source_id, kind, ref_id, title, deflections, value_cents]
      properties:
        source_id: { $ref: "#/components/schemas/Ulid" }
        kind: { type: string, enum: [guide, article, page, crawl, upload] }
        ref_id: { type: [string, "null"], description: The native content id (guide/article) when the source has one. }
        title: { type: string }
        deflections: { type: number, description: Credited deflected contacts — fractional when an answer was grounded by several sources. }
        value_cents: { type: integer, description: "`deflections` × `value_per_contact_cents`, rounded." }

    KnowledgeHealthRoi:
      type: object
      description: >
        ATH-285 (Doc 07 §1 "ROI attribution") — what the self-healing loop
        saved, over a trailing window. Read from the `metric_rollups`
        buckets the ROI rollup writes (ATH-141 interface), never from raw
        runs.


        A DEFLECTED CONTACT is a visitor need resolved without a human:
        (a) a conversation whose agent runs in the window answered at
        least once and never escalated, and (b) a walkthrough run to
        completion. The value model is a single configurable
        cost-per-human-contact (`value_per_contact_cents`,
        `CAIRN_DEFLECTION_VALUE_CENTS`) — the doc names the assumption
        but no number, so the shipped default is documented, not implied.
      required:
        [from, to, deflections_agent, deflections_walkthrough, deflections_total,
         escalations, deflection_rate, value_cents, value_per_contact_cents, by_source]
      properties:
        from: { type: string, format: date-time, description: Window start (inclusive) — midnight of the first rolled-up day. }
        to: { type: string, format: date-time, description: Window end (exclusive). }
        deflections_agent: { type: number, description: Conversations the AI resolved with no escalation in the window. }
        deflections_walkthrough: { type: number, description: Walkthroughs run to completion in the window. }
        deflections_total: { type: number }
        escalations: { type: number, description: Conversations that reached a human — the deflection-rate denominator. }
        deflection_rate:
          type: number
          description: "`deflections_agent / (deflections_agent + escalations)`; 0 when the agent handled nothing."
        value_cents: { type: integer, description: "`deflections_total` × `value_per_contact_cents` — the headline saving." }
        value_per_contact_cents: { type: integer, description: "The cost-per-human-contact assumption the math above uses, surfaced so the number is auditable." }
        by_source:
          type: array
          items: { $ref: "#/components/schemas/KnowledgeHealthRoiSource" }

    AgentSettings:
      type: object
      description: >
        The agent's tuning bag. These are the keys AgentSettingsController
        validates — the read and write shapes reference this same schema,
        so they cannot drift apart.


        PA-271 — the bag is CLOSED (`additionalProperties: false`): on
        write, a key not listed here is rejected with 422 rather than
        stored or silently dropped. Unlike `Workspace.settings`, which is
        free-form by contract, every key here has a declared type and a
        reader, so an unknown one is a typo or a client built against a
        contract this server does not implement — both worth telling the
        caller about.
      additionalProperties: false
      properties:
        answer_length: { type: string, enum: [short, medium, long] }
        blocked_topics: { type: array, items: { type: string, maxLength: 120 }, maxItems: 50 }
        languages: { type: array, items: { type: string, maxLength: 16 }, maxItems: 50 }
        escalate_on_low_confidence:
          type: boolean
          description: >
            PA-274 — whether a low-confidence turn opens a HUMAN escalation
            (inbox routing + AI summary note). Absent means true. Off, the
            visitor still gets the honest handoff message and the knowledge
            gap is still logged — the toggle governs routing a person, never
            the refusal to guess.

    AgentProfile:
      type: object
      additionalProperties: false
      required: [id, name, persona, instructions, status, settings, channels]
      properties:
        id: { $ref: "#/components/schemas/Ulid" }
        name: { type: string }
        persona: { type: [string, "null"] }
        instructions: { type: [string, "null"] }
        status: { type: string, enum: [inactive, active, review, shadow], description: "review = AI drafts + human approves; shadow = AI runs silently for evaluation" }
        settings: { $ref: "#/components/schemas/AgentSettings" }
        channels:
          type: array
          items: { type: string, enum: [widget, email] }

    AgentProfileInput:
      type: object
      properties:
        name: { type: string, maxLength: 120 }
        persona: { type: [string, "null"], maxLength: 2000 }
        instructions: { type: [string, "null"], maxLength: 5000 }
        status: { type: string, enum: [inactive, active, review, shadow] }
        settings: { $ref: "#/components/schemas/AgentSettings" }
        channels:
          type: array
          items: { type: string, enum: [widget, email] }

    KnowledgeUpload:
      type: object
      additionalProperties: false
      required: [id, status]
      properties:
        id: { $ref: "#/components/schemas/Ulid" }
        filename: { type: [string, "null"] }
        title: { type: [string, "null"] }
        audience: { type: string, enum: [public, customers, internal] }
        size: { type: [integer, "null"] }
        status: { type: string, enum: [pending, syncing, synced, error, disabled] }
        error: { type: [string, "null"] }
        last_synced_at: { type: [string, "null"], format: date-time }
        created_at: { type: [string, "null"], format: date-time }

    ContactProfile:
      type: object
      required:
        [id, email, name, phone, verified, attributes, last_seen_at, created_at, unsubscribed_at,
         suppression_reason]
      properties:
        id: { $ref: "#/components/schemas/Ulid" }
        email: { type: [string, "null"], format: email }
        name: { type: [string, "null"] }
        phone: { type: [string, "null"] }
        verified: { type: boolean }
        attributes:
          type: object
          description: >
            Freeform per-contact attributes. Values are SCALARS only —
            ContactController rejects nested structures, so no blob can
            be smuggled in under a custom key.
          additionalProperties: { type: [string, number, boolean, "null"] }
        last_seen_at: { type: [string, "null"], format: date-time }
        created_at: { type: [string, "null"], format: date-time }
        unsubscribed_at:
          type: [string, "null"]
          format: date-time
          description: >
            ATH-211 — email consent (Doc 04 §6 suppression list). Non-null
            means SUPPRESSED: no campaign will ever send to this contact
            again. Set by the one-click unsubscribe link or by a hard
            bounce; cleared only by an explicit resubscribe.
        suppression_reason:
          type: [string, "null"]
          description: >
            Why the contact was suppressed — one of `unsubscribe`,
            `hard_bounce`, `complaint`, `manual`; null while subscribed.

    SegmentFilterNode:
      type: object
      required: [kind, value]
      description: >
        ATH-210 — one node of a segment's boolean filter tree (Doc 04
        §6). Which other properties apply depends on `kind`: and/or
        carry `children`; not carries `child`; attribute carries
        field/op/value; custom_attribute carries key/op/value (numeric
        ops compare only numerically-typed or numeric-string values);
        channel carries fact/value; last_seen and created carry op
        (within_days | not_within_days — the latter includes never-seen)
        and days; event_count carries event/min_count/within_days over
        contact_events; visited_page carries match (case-insensitive
        substring against payload.url of page.viewed contact_events) and
        within_days. The server validates the tree kind-by-kind and 422s
        with node paths; depth is capped at 6 and total nodes at 60.
      properties:
        kind:
          type: string
          enum: [and, or, not, attribute, custom_attribute, channel, last_seen, created, event_count, visited_page]
        children:
          type: array
          maxItems: 20
          items: { $ref: "#/components/schemas/SegmentFilterNode" }
        child: { $ref: "#/components/schemas/SegmentFilterNode" }
        field: { type: string, enum: [email, name, phone, external_id] }
        key:
          type: string
          maxLength: 64
          description: Custom attribute key (word characters, spaces, dots, hyphens).
        op:
          type: string
          enum: [eq, neq, contains, starts_with, gt, gte, lt, lte, is_set, is_not_set, within_days, not_within_days]
        value:
          oneOf:
            - { type: string, maxLength: 255 }
            - { type: number }
            - { type: boolean }
        fact: { type: string, enum: [has_email, has_phone, verified] }
        days: { type: integer, minimum: 1, maximum: 3650 }
        event: { type: string, maxLength: 64 }
        min_count: { type: integer, minimum: 1, maximum: 10000 }
        within_days: { type: [integer, "null"], minimum: 1, maximum: 3650 }
        match: { type: string, maxLength: 500 }

    Segment:
      type: object
      additionalProperties: false
      required: [id, name, definition, contact_count, refreshed_at, created_at, updated_at]
      properties:
        id: { $ref: "#/components/schemas/Ulid" }
        name: { type: string }
        definition: { $ref: "#/components/schemas/SegmentFilterNode" }
        contact_count:
          type: integer
          description: >
            Materialized size — as of refreshed_at, not live. 0 with a
            null refreshed_at means the first materialization is still
            queued.
        refreshed_at: { type: [string, "null"], format: date-time }
        created_at: { type: [string, "null"], format: date-time }
        updated_at: { type: [string, "null"], format: date-time }

    SegmentPreview:
      type: object
      additionalProperties: false
      required: [count, sample]
      properties:
        count: { type: integer }
        sample:
          type: array
          maxItems: 10
          description: Most-recently-seen matching contacts.
          items: { $ref: "#/components/schemas/ContactProfile" }

    CampaignInput:
      type: object
      description: ATH-211 — writable campaign fields (Doc 04 §6).
      properties:
        name: { type: string, maxLength: 120 }
        channel:
          type: string
          enum: [email, in_app]
          default: email
          description: >
            ATH-212 — how the campaign reaches its audience. `email`
            blasts through the mail pipeline the moment it is sent;
            `in_app` writes into each contact's widget conversation the
            next time that contact is present in the widget, so an
            in_app campaign stays `sending` for as long as audience
            members have yet to show up. Immutable once the campaign has
            left `draft`/`scheduled` — the audience is already claimed
            against one delivery mechanism.


            `in_app` additionally requires the
            `feature.messaging.in_app_campaigns` flag to be active for
            the workspace; without it, writes 422 and nothing is
            delivered to the widget.
        subject: { type: string, maxLength: 255 }
        body:
          type: string
          maxLength: 20000
          description: >
            Plain-text body with merge fields. Syntax is deliberately
            small: `{{ name }}`, `{{ email }}`, `{{ phone }}` and
            `{{ external_id }}` read the contact's standard columns, and
            `{{ attr.some_key }}` reads a custom attribute. Unknown or
            empty fields render as an empty string — a campaign never
            leaks a raw `{{ … }}` into a customer's inbox. Nothing else
            is interpreted; the body is not a template language.
        segment_id:
          type: [string, "null"]
          description: >
            The audience. Its MATERIALIZED membership is the recipient
            list, so a segment whose refresh has not landed yet sends to
            what the snapshot currently holds. Required before sending.
        scheduled_at:
          type: [string, "null"]
          format: date-time
          description: Set via the send endpoint; read-only here except to clear it.

    Campaign:
      type: object
      required:
        [id, name, channel, subject, body, segment_id, status, scheduled_at, stats, created_at,
         updated_at, started_at, completed_at]
      properties:
        id: { $ref: "#/components/schemas/Ulid" }
        name: { type: string }
        channel: { type: string, enum: [email, in_app] }
        subject: { type: string }
        body: { type: string }
        segment_id: { type: [string, "null"] }
        status:
          type: string
          enum: [draft, scheduled, sending, sent, paused]
          description: >
            draft → scheduled (a future send) or sending (now) → sent.
            paused stops further recipients launching; already-claimed
            recipients still deliver.


            ATH-212 — an `in_app` campaign reaches `sent` only once every
            recipient has either been delivered to or skipped, which can
            take as long as the audience takes to visit. A long-running
            `sending` is the honest state for it, not a stuck one.
        scheduled_at: { type: [string, "null"], format: date-time }
        started_at: { type: [string, "null"], format: date-time }
        completed_at: { type: [string, "null"], format: date-time }
        stats: { $ref: "#/components/schemas/CampaignStats" }
        created_at: { type: [string, "null"], format: date-time }
        updated_at: { type: [string, "null"], format: date-time }

    CampaignStats:
      type: object
      additionalProperties: false
      required: [recipients, sent, skipped, failed, bounced, unsubscribed]
      description: >
        ATH-211 — per-campaign counters (Doc 04 §6 "per-campaign
        analytics"). `recipients` is the audience size resolved at
        fan-out; `skipped` counts contacts excluded for suppression or a
        missing email address; `bounced` and `unsubscribed` accrue after
        delivery, from provider callbacks and unsubscribe clicks.
      properties:
        recipients: { type: integer }
        sent: { type: integer }
        skipped: { type: integer }
        failed: { type: integer }
        bounced: { type: integer }
        unsubscribed: { type: integer }

    InAppCampaignMessage:
      type: object
      required: [campaign_id, conversation_id, message_id, body]
      description: >
        ATH-212 — one in-app campaign message, delivered to the calling
        visitor (Doc 04 §6). The message already exists in the named
        conversation by the time this is returned; the fields here let
        the widget show it proactively without opening the thread first.
      properties:
        campaign_id: { $ref: "#/components/schemas/Ulid" }
        conversation_id:
          $ref: "#/components/schemas/Ulid"
        message_id:
          $ref: "#/components/schemas/Ulid"
        body:
          type: string
          description: >
            The campaign body with merge fields already rendered against
            this contact — the same rendering the email channel does.

    SubInbox:
      type: object
      additionalProperties: false
      required: [id, name, kind]
      properties:
        id: { $ref: "#/components/schemas/Ulid" }
        name: { type: string }
        kind: { type: string, enum: [general, custom] }
        routing_rules:
          type: array
          items:
            type: object
            required: [type, value]
            properties:
              type: { type: string, enum: [channel, keyword] }
              value: { type: string }

    SubInboxInput:
      type: object
      properties:
        name: { type: string, maxLength: 255 }
        routing_rules:
          type: array
          maxItems: 5
          items:
            type: object
            required: [type, value]
            properties:
              type: { type: string, enum: [channel, keyword] }
              value: { type: string, maxLength: 100 }

    ConversationSummary:
      type: object
      additionalProperties: false
      required:
        [id, state, channel, subject, assignee_id, contact, preview, rating, last_message_at, kind,
         reference, priority, sla_breached, quarantined, translate_enabled]
      properties:
        id: { $ref: "#/components/schemas/Ulid" }
        state: { type: string, enum: [pending, open, snoozed, resolved] }
        channel: { type: string }
        subject: { type: [string, "null"] }
        assignee_id: { type: [string, "null"] }
        contact:
          type: [object, "null"]
          properties:
            id: { $ref: "#/components/schemas/Ulid" }
            name: { type: [string, "null"] }
            email: { type: [string, "null"] }
            verified: { type: boolean }
        preview: { type: [string, "null"] }
        rating: { type: [integer, "null"], minimum: 1, maximum: 5, description: CSAT score (ATH-134) }
        last_message_at: { type: [string, "null"], format: date-time }
        kind:
          type: string
          enum: [conversation, ticket]
          description: >
            ATH-214 — a ticket is a conversation with kind=ticket
            (Doc 04 §7), so it appears in this list like any other. The
            three fields below are null on an ordinary conversation.
        reference: { type: [string, "null"], description: "Ticket reference (ATH-214)" }
        # No enum here even though the values are the four priorities: a
        # bare `null` enum member crashes spectral with a null-read and
        # no line number, and quoting it would mean the STRING "null".
        # The nullable type plus this note is the honest encoding.
        priority: { type: [string, "null"], description: "low | normal | high | urgent; null on a non-ticket conversation" }
        sla_breached: { type: boolean, description: "Either ticket SLA deadline has been marked breached (ATH-214)" }
        quarantined:
          type: boolean
          description: >
            ATH-221 — the AI spam filter (or a human) quarantined this
            conversation. Quarantined rows are EXCLUDED from this list
            unless `quarantined=true` is passed, which is the spam
            folder. Nothing is deleted; releasing it is
            POST .../spam with spam=false.
        translate_enabled:
          type: boolean
          description: >
            ATH-223 — LiveTranslate is running on this conversation
            (Doc 04 §9). Always present so the thread header renders the
            toggle in the right state without a second fetch.

    AiConversationSummary:
      type: object
      additionalProperties: false
      required:
        [conversation_id, summary, key_points, stale, credit_cost_cents, message_count,
         generated_at, trigger, prompt_version]
      description: >
        ATH-221 — the AI summary of one conversation (Doc 05 §7). One
        row per conversation, regenerated in place.
      properties:
        conversation_id: { $ref: "#/components/schemas/Ulid" }
        summary: { type: string, description: A few sentences a teammate can read before picking the thread up }
        key_points:
          type: array
          description: Short bullets — what the customer wants, what has been tried, what is outstanding.
          items: { type: string }
        message_count:
          type: integer
          description: How many messages the transcript held when this was generated.
        stale:
          type: boolean
          description: >
            Messages have arrived since this summary was generated, so it
            no longer describes the whole thread. Computed on read rather
            than stored, and surfaced instead of silently hiding the
            summary — a slightly old summary is still useful as long as
            the reader knows.
        generated_at: { type: [string, "null"], format: date-time }
        trigger:
          type: string
          enum: [manual, resolved]
          description: What produced this summary — a teammate pressing the button, or the conversation being resolved.
        prompt_version: { type: [string, "null"], description: Which pinned prompt version produced it (Doc 05 §2 reproducibility) }
        credit_cost_cents: { type: integer, description: What this generation debited. Zero on an unlimited plan. }

    VoiceTranscript:
      type: object
      additionalProperties: false
      required: [text, duration_seconds, credit_cost_cents, minutes_charged]
      description: >
        ATH-222 — what a teammate said, as text for the composer (Doc 05
        §7). There is deliberately no id, no url and no audio reference:
        this object is the whole of what the request produced, the
        recording behind it no longer exists, and nothing about it is
        persisted server-side. If the client drops this payload, the
        dictation is gone.
      properties:
        text:
          type: string
          description: The transcript. Never empty — no-speech audio is a 422, not a blank string.
        duration_seconds:
          type: integer
          description: Audio length the provider actually transcribed, rounded up. Zero only for fixture-mode transcripts with no segments.
        minutes_charged:
          type: integer
          description: Whole minutes debited, rounded up. Zero on an unlimited plan.
        credit_cost_cents:
          type: integer
          description: What this dictation debited. Zero on an unlimited plan.

    ConversationTag:
      type: object
      additionalProperties: false
      required: [tag, source, confidence, applied_at, applied_by]
      description: >
        ATH-221 — one tag on a conversation, WITH PROVENANCE. The
        provenance is the point: auto-tagging applies tags rather than
        suggesting them, so every tag has to say who put it there.
      properties:
        tag: { type: string, maxLength: 50 }
        source:
          type: string
          enum: [ai, human, workflow]
          description: >
            ai = the auto-tag classifier; human = a teammate through
            POST .../tags; workflow = a workflow tag node (ATH-192).
        confidence:
          type: [number, "null"]
          format: float
          description: "The classifier's confidence. Null for human and workflow tags, which are not guesses."
        applied_at: { type: [string, "null"], format: date-time }
        applied_by: { type: [string, "null"], description: User id for human tags; null for ai and workflow }

    AutoTagResult:
      type: object
      additionalProperties: false
      required: [applied, skipped, tags, credit_cost_cents, vocabulary_empty]
      description: >
        ATH-221 — the outcome of one auto-tag classification.
      properties:
        applied:
          type: array
          description: Tags this call added to the conversation.
          items: { type: string }
        skipped:
          type: array
          description: >
            Tags the model returned that were NOT applied, with the
            reason. A model naming a tag outside the workspace
            vocabulary is dropped rather than silently creating new
            vocabulary; a tag already present is dropped as a duplicate.
          items:
            type: object
            required: [tag, reason]
            properties:
              tag: { type: string }
              reason: { type: string, enum: [not_in_vocabulary, already_applied, low_confidence] }
        tags:
          type: array
          description: The conversation's full tag set after this call, with provenance.
          items: { $ref: "#/components/schemas/ConversationTag" }
        vocabulary_empty:
          type: boolean
          description: >
            The workspace has no tags yet, so there was nothing to
            classify against and no model call was made. Auto-tagging
            picks from the workspace's OWN vocabulary and will not
            bootstrap one.
        credit_cost_cents: { type: integer }

    SpamVerdict:
      type: object
      additionalProperties: false
      required:
        [conversation_id, verdict, quarantined, source, confidence, reason, classified_at,
         prompt_version, reversed_at, reversed_by]
      description: >
        ATH-221 — the spam classification of one conversation (Doc 05
        §7). Nothing here deletes anything: a spam verdict quarantines,
        and quarantine is reversible.
      properties:
        conversation_id: { $ref: "#/components/schemas/Ulid" }
        verdict: { type: string, enum: [spam, ham] }
        quarantined:
          type: boolean
          description: Whether the conversation is currently held out of the inbox list.
        source:
          type: string
          enum: [ai, human]
          description: Who reached this verdict — the classifier, or a teammate overriding it.
        confidence: { type: [number, "null"], format: float }
        reason:
          type: [string, "null"]
          maxLength: 500
          description: "One line of why — the model's reasoning, or the human's note"
        classified_at: { type: [string, "null"], format: date-time }
        prompt_version:
          type: [string, "null"]
          description: >
            Which pinned prompt version produced the original
            classification (Doc 05 §2 reproducibility). Null only when no
            classifier ever ran — a teammate quarantining by hand. It
            describes the classification, NOT the current stance, so it
            is retained when a human takes the stance over and `source`
            flips to `human`; do not read a non-null value here as
            meaning the AI still owns the verdict.

            Paired with `reversed_at` this is what makes the filter's
            false-positive rate attributable to a prompt version rather
            than to the feature as a whole — without it a workspace can
            see that the filter was wrong but not which version was
            wrong, so a prompt change can never be shown to have helped.
        reversed_at:
          type: [string, "null"]
          format: date-time
          description: >
            When a human overturned the AI verdict. Set on the ORIGINAL
            verdict rather than replacing it, so a false positive stays
            visible as one — that record is how the filter's error rate
            is ever measured.
        reversed_by: { type: [string, "null"], description: User id who overturned it }

    InboxMessage:
      type: object
      additionalProperties: false
      required: [id, direction, author_type, created_at]
      properties:
        id: { $ref: "#/components/schemas/Ulid" }
        direction: { type: string, enum: [in, out, note] }
        author_type: { type: string }
        author_id: { type: [string, "null"] }
        body: { type: [string, "null"] }
        translation:
          oneOf: [{ $ref: "#/components/schemas/MessageTranslation" }, { type: "null" }]
          description: >
            ATH-223 — the machine translation of this message into the
            team's reading language (Doc 04 §9), or null when the thread
            is untranslated or this message has not been translated yet.


            `body` above is ALWAYS the original and is never replaced.
            The agent reads the translation and can see the source: a
            translation shown without its original cannot be challenged
            by the one person in the conversation who knows what was
            meant, and an agent about to issue a refund on the strength
            of a machine rendering should be able to look at what the
            customer actually typed.
        read_at: { type: [string, "null"], format: date-time }
        created_at: { type: string, format: date-time }

    TicketSummary:
      type: object
      required:
        [id, reference, state, priority, created_at, subject, channel, assignee_id, contact,
         first_response_due_at, resolution_due_at, first_response_breached_at,
         resolution_breached_at, last_message_at]
      properties:
        id: { $ref: "#/components/schemas/Ulid" }
        reference: { type: string, description: "Per-workspace human reference, e.g. TKT-1042" }
        subject: { type: [string, "null"] }
        state: { type: string, enum: [pending, open, snoozed, resolved] }
        priority: { type: string, enum: [low, normal, high, urgent] }
        channel: { type: string }
        assignee_id: { type: [string, "null"] }
        contact:
          type: [object, "null"]
          properties:
            id: { $ref: "#/components/schemas/Ulid" }
            name: { type: [string, "null"] }
            email: { type: [string, "null"] }
        first_response_due_at: { type: [string, "null"], format: date-time }
        resolution_due_at: { type: [string, "null"], format: date-time }
        first_response_breached_at: { type: [string, "null"], format: date-time }
        resolution_breached_at: { type: [string, "null"], format: date-time }
        last_message_at: { type: [string, "null"], format: date-time }
        created_at: { type: string, format: date-time }

    Ticket:
      allOf:
        - $ref: "#/components/schemas/TicketSummary"
        - type: object
          properties:
            portal_visible:
              type: boolean
              description: >
                Whether the requesting contact sees this ticket in the
                customer portal. Tickets opened FROM the portal are
                visible by default; agent-opened ones are not, so an
                internal ticket about a customer is never disclosed to
                them by accident.
            first_responded_at: { type: [string, "null"], format: date-time }
            resolved_at: { type: [string, "null"], format: date-time }
            fields:
              type: object
              additionalProperties: true
              description: "Custom ticket fields — a free-form map for v1 (typed per-workspace definitions are not built yet)"

    TicketInput:
      type: object
      required: [subject]
      properties:
        subject: { type: string, maxLength: 200 }
        contact_id: { $ref: "#/components/schemas/Ulid" }
        priority: { type: string, enum: [low, normal, high, urgent], default: normal }
        assignee_id: { type: [string, "null"] }
        body:
          type: [string, "null"]
          maxLength: 10000
          description: "Opening message; written through the shared message path when present"
        portal_visible: { type: boolean, default: false }
        fields: { type: object, additionalProperties: true }

    TicketUpdateInput:
      type: object
      properties:
        subject: { type: string, maxLength: 200 }
        priority: { type: string, enum: [low, normal, high, urgent] }
        portal_visible: { type: boolean }
        fields: { type: object, additionalProperties: true }
        first_response_due_at: { type: [string, "null"], format: date-time }
        resolution_due_at: { type: [string, "null"], format: date-time }

    StatusPageSettings:
      type: object
      additionalProperties: false
      required: [workspace_id, published, title]
      properties:
        workspace_id: { $ref: "#/components/schemas/Ulid" }
        published:
          type: boolean
          description: "While false the public page 404s on every host — the status page exists but has not been announced."
        title: { type: string, maxLength: 120 }
        description: { type: [string, "null"], maxLength: 2000 }
        support_url: { type: [string, "null"], maxLength: 2048 }
        created_at: { type: [string, "null"], format: date-time }
        updated_at: { type: [string, "null"], format: date-time }

    StatusPageSettingsInput:
      type: object
      description: "Every field optional; only what is supplied changes."
      properties:
        published: { type: boolean }
        title: { type: string, minLength: 1, maxLength: 120 }
        description: { type: [string, "null"], maxLength: 2000 }
        support_url: { type: [string, "null"], maxLength: 2048 }

    StatusComponent:
      type: object
      additionalProperties: false
      required: [id, workspace_id, name, status, published, position, description, created_at, updated_at]
      properties:
        id: { $ref: "#/components/schemas/Ulid" }
        workspace_id: { $ref: "#/components/schemas/Ulid" }
        name: { type: string, maxLength: 120 }
        description: { type: [string, "null"], maxLength: 2000 }
        status:
          type: string
          enum: [operational, degraded, partial_outage, major_outage, maintenance]
          description: "The component's CURRENT state as shown on the public page."
        published:
          type: boolean
          description: "False keeps the component out of the public page without deleting it — an internal dependency a workspace tracks but does not disclose."
        position: { type: integer, minimum: 0 }
        created_at: { type: [string, "null"], format: date-time }
        updated_at: { type: [string, "null"], format: date-time }

    StatusComponentInput:
      type: object
      required: [name]
      properties:
        name: { type: string, minLength: 1, maxLength: 120 }
        description: { type: [string, "null"], maxLength: 2000 }
        status: { type: string, enum: [operational, degraded, partial_outage, major_outage, maintenance], default: operational }
        published: { type: boolean, default: true }
        position: { type: integer, minimum: 0 }

    StatusComponentPatchInput:
      type: object
      description: "Every field optional; only what is supplied changes."
      properties:
        name: { type: string, minLength: 1, maxLength: 120 }
        description: { type: [string, "null"], maxLength: 2000 }
        status: { type: string, enum: [operational, degraded, partial_outage, major_outage, maintenance] }
        published: { type: boolean }
        position: { type: integer, minimum: 0 }

    StatusIncidentSummary:
      type: object
      required:
        [id, workspace_id, title, status, impact, published, started_at, origin, component_ids,
         resolved_at, created_at, updated_at]
      properties:
        id: { $ref: "#/components/schemas/Ulid" }
        workspace_id: { $ref: "#/components/schemas/Ulid" }
        title: { type: string, maxLength: 200 }
        status: { type: string, enum: [investigating, identified, monitoring, resolved] }
        impact: { type: string, enum: [none, minor, major, critical] }
        published:
          type: boolean
          description: "False keeps the incident off the public page — a dry run or an internal-only event."
        origin:
          type: string
          enum: [manual, monitor]
          description: "Whether a human declared this incident or a monitor opened it automatically."
        component_ids:
          type: array
          items: { $ref: "#/components/schemas/Ulid" }
        started_at: { type: string, format: date-time }
        resolved_at: { type: [string, "null"], format: date-time }
        created_at: { type: [string, "null"], format: date-time }
        updated_at: { type: [string, "null"], format: date-time }

    StatusIncident:
      allOf:
        - $ref: "#/components/schemas/StatusIncidentSummary"
        - type: object
          properties:
            updates:
              type: array
              description: "The append-only timeline, oldest first."
              items: { $ref: "#/components/schemas/StatusIncidentUpdate" }

    StatusIncidentUpdate:
      type: object
      required: [id, status, body, created_at]
      properties:
        id: { $ref: "#/components/schemas/Ulid" }
        status: { type: string, enum: [investigating, identified, monitoring, resolved] }
        body: { type: string, maxLength: 5000 }
        created_at: { type: string, format: date-time }

    StatusIncidentInput:
      type: object
      required: [title, body]
      properties:
        title: { type: string, minLength: 1, maxLength: 200 }
        body:
          type: string
          minLength: 1
          maxLength: 5000
          description: "The first update. Required, because an incident with no explanation tells a customer nothing they did not already know."
        status: { type: string, enum: [investigating, identified, monitoring, resolved], default: investigating }
        impact: { type: string, enum: [none, minor, major, critical], default: minor }
        published: { type: boolean, default: true }
        component_ids:
          type: array
          items: { $ref: "#/components/schemas/Ulid" }
        started_at: { type: string, format: date-time }

    StatusIncidentPatchInput:
      type: object
      description: "Every field optional; only what is supplied changes. The lifecycle status is deliberately absent — move it by appending an update."
      properties:
        title: { type: string, minLength: 1, maxLength: 200 }
        impact: { type: string, enum: [none, minor, major, critical] }
        published: { type: boolean }
        component_ids:
          type: array
          items: { $ref: "#/components/schemas/Ulid" }

    StatusIncidentUpdateInput:
      type: object
      required: [body]
      properties:
        body: { type: string, minLength: 1, maxLength: 5000 }
        status:
          type: string
          enum: [investigating, identified, monitoring, resolved]
          description: "Moves the incident's lifecycle. Omit to comment without changing it."

    StatusMonitor:
      type: object
      additionalProperties: false
      required: [id, workspace_id, name, url, method, enabled]
      properties:
        id: { $ref: "#/components/schemas/Ulid" }
        workspace_id: { $ref: "#/components/schemas/Ulid" }
        name: { type: string, maxLength: 120 }
        url:
          type: string
          format: uri
          description: "Workspace-supplied target. Checked against the SSRF guard's DNS-free rules at save time and again, with DNS resolution and per-redirect-hop re-checking, on every sweep."
        method: { type: string, enum: [GET, HEAD] }
        expected_status: { type: integer, minimum: 100, maximum: 599 }
        interval_seconds: { type: integer, minimum: 60, maximum: 86400 }
        timeout_seconds: { type: integer, minimum: 1, maximum: 30 }
        failure_threshold:
          type: integer
          minimum: 1
          maximum: 20
          description: "Consecutive failures before an incident opens. Above 1 so a single blip does not page a customer."
        recovery_threshold:
          type: integer
          minimum: 1
          maximum: 20
          description: "Consecutive successes before the auto-opened incident resolves."
        component_id:
          type: [string, "null"]
          description: "The component this monitor drives. When set, an auto-opened incident marks it as affected and moves its status."
        enabled: { type: boolean }
        consecutive_failures: { type: integer, minimum: 0 }
        consecutive_successes: { type: integer, minimum: 0 }
        open_incident_id:
          type: [string, "null"]
          description: "The incident this monitor currently owns. Non-null IS the claim that stops a second incident being opened for the same outage."
        last_checked_at: { type: [string, "null"], format: date-time }
        next_check_at: { type: [string, "null"], format: date-time }
        created_at: { type: [string, "null"], format: date-time }
        updated_at: { type: [string, "null"], format: date-time }

    StatusMonitorInput:
      type: object
      required: [name, url]
      properties:
        name: { type: string, minLength: 1, maxLength: 120 }
        url: { type: string, format: uri, maxLength: 2048 }
        method: { type: string, enum: [GET, HEAD], default: GET }
        expected_status: { type: integer, minimum: 100, maximum: 599, default: 200 }
        interval_seconds: { type: integer, minimum: 60, maximum: 86400, default: 300 }
        timeout_seconds: { type: integer, minimum: 1, maximum: 30, default: 10 }
        failure_threshold: { type: integer, minimum: 1, maximum: 20, default: 3 }
        recovery_threshold: { type: integer, minimum: 1, maximum: 20, default: 2 }
        component_id: { type: [string, "null"] }
        enabled: { type: boolean, default: true }

    StatusMonitorPatchInput:
      type: object
      description: "Every field optional; only what is supplied changes."
      properties:
        name: { type: string, minLength: 1, maxLength: 120 }
        url: { type: string, format: uri, maxLength: 2048 }
        method: { type: string, enum: [GET, HEAD] }
        expected_status: { type: integer, minimum: 100, maximum: 599 }
        interval_seconds: { type: integer, minimum: 60, maximum: 86400 }
        timeout_seconds: { type: integer, minimum: 1, maximum: 30 }
        failure_threshold: { type: integer, minimum: 1, maximum: 20 }
        recovery_threshold: { type: integer, minimum: 1, maximum: 20 }
        component_id: { type: [string, "null"] }
        enabled: { type: boolean }

    StatusMonitorCheck:
      type: object
      required: [id, ok, checked_at]
      properties:
        id: { $ref: "#/components/schemas/Ulid" }
        ok: { type: boolean }
        status_code: { type: [integer, "null"] }
        latency_ms: { type: [integer, "null"] }
        error:
          type: [string, "null"]
          description: "Why the check failed. For a target the SSRF guard refused this carries the guard's author-facing reason, which never leaks network topology."
        checked_at: { type: string, format: date-time }

    WidgetTrigger:
      type: object
      additionalProperties: false
      required: [id, name, enabled, conditions, message]
      properties:
        id: { $ref: "#/components/schemas/Ulid" }
        name: { type: string }
        enabled: { type: boolean }
        conditions:
          type: array
          items:
            type: object
            required: [type, value]
            properties:
              type: { type: string, enum: [url_matches, time_on_page, scroll_percent, event] }
              value: {}
        message: { type: string, maxLength: 500 }

    WidgetTriggerInput:
      type: object
      properties:
        name: { type: string, maxLength: 255 }
        enabled: { type: boolean }
        message: { type: string, maxLength: 500 }
        conditions:
          type: array
          minItems: 1
          maxItems: 5
          items:
            type: object
            required: [type, value]
            properties:
              type: { type: string, enum: [url_matches, time_on_page, scroll_percent, event] }
              value: {}

    WidgetSettings:
      type: object
      required:
        [position, launcher_color, greeting, working_hours, offline_form,
         struggle_detection_enabled, nudge_sensitivity, nudge_page_blocklist]
      properties:
        position: { type: string, enum: [left, right] }
        launcher_color: { type: [string, "null"], description: "#rrggbb; null = brand primary" }
        greeting: { type: [string, "null"], maxLength: 200 }
        working_hours:
          type: [object, "null"]
          description: "{timezone, days: {mon: [start, end], …}}; missing day = closed; null = always online"
        offline_form: { type: boolean }
        struggle_detection_enabled:
          type: boolean
          description: >
            PA-229 (Doc 07 §4) — the real per-workspace enablement PA-227
            deferred: when true (and `feature.guidance.nudges` is active) the
            client starts the struggle detectors and the nudge engine. The
            public widget config resolves (flag AND this setting) into its
            `struggle_detection` boolean; this row is the setting half.
            Default false.
        nudge_sensitivity:
          type: string
          enum: [low, medium, high]
          description: >
            PA-229 — the tuning slider. Maps client-side to which struggle
            kinds may nudge and the frequency-cap cooldowns (low = fewer,
            quieter nudges; high = more, sooner). Default `medium`.
        nudge_page_blocklist:
          type: array
          maxItems: 200
          description: >
            PA-229 — URL patterns (the same `urlMatches` rule the page map
            uses) where nudges are suppressed entirely, even on a struggle
            signal. Default empty.
          items: { type: string, maxLength: 2048 }

    WidgetMessage:
      type: object
      additionalProperties: false
      required: [id, direction, author_type, created_at, kind, body, payload, answer, translation, read_at]
      properties:
        id: { $ref: "#/components/schemas/Ulid" }
        direction: { type: string, enum: [in, out] }
        author_type: { type: string, enum: [contact, visitor, user, ai_agent, workflow] }
        kind: { type: string, enum: [text, file, rich, event] }
        body: { type: [string, "null"] }
        payload: { type: [object, "null"], description: Present on event-kind messages (e.g. csat_request) }
        answer: { oneOf: [{ $ref: "#/components/schemas/AgentAnswer" }, { type: "null" }], description: "ATH-161 — present on ai_agent messages: the answer type + citations the widget renders" }
        translation:
          oneOf: [{ $ref: "#/components/schemas/MessageTranslation" }, { type: "null" }]
          description: >
            ATH-223 — the machine translation of this reply into the
            visitor's language (Doc 04 §9), or null. `body` above is
            ALWAYS the original and is never replaced by it. The widget
            renders the translation as the primary text with a
            disclosure badge and the original one tap away — an
            undisclosed machine translation in a support conversation is
            the trust problem this field exists to avoid.
        read_at: { type: [string, "null"], format: date-time }
        created_at: { type: string, format: date-time }

    MessageTranslation:
      type: object
      required: [body, target_locale, audience]
      description: >
        ATH-223 — one machine translation of one message (Doc 04 §9).


        A SIDECAR, never a replacement. The message's own `body` holds
        what the author actually wrote and is never rewritten: the
        original has to survive because a support conversation is
        evidence in refund disputes, chargebacks and subject-access
        requests, because re-translating with a better model later is
        impossible without the source, and because the only person who
        can catch a mistranslation is the one who wrote the original.
      properties:
        body: { type: string, description: The translated text. Never empty — an empty translation is discarded rather than stored. }
        target_locale: { type: string, maxLength: 24, description: Canonical BCP-47 tag this was translated INTO. }
        source_locale:
          type: [string, "null"]
          maxLength: 24
          description: >
            What the model detected the source to be, per MESSAGE rather
            than per conversation — a visitor who switches language
            mid-thread is an ordinary case. `und` when the text was too
            short or ambiguous to judge, which is recorded honestly and
            never used to route the next reply.
        audience:
          type: string
          enum: [agent, visitor]
          description: >
            Who this rendering is for, which decides the realtime
            channel it may travel on. `agent` renderings are inbound
            customer messages in the team's language and never reach the
            visitor; `visitor` renderings are outbound replies in the
            customer's language.
        confidence:
          type: [number, "null"]
          description: >
            The model's calibrated judgement that this is safe to show
            unreviewed. Shown to the agent next to the translation — a
            translation nobody can calibrate has to be either trusted
            completely or ignored completely.
        prompt_version: { type: [string, "null"], description: Which pinned prompt version produced it (Doc 05 §2 reproducibility) }
        credit_cost_cents:
          type: integer
          description: >
            What this translation debited. Zero on an unlimited plan,
            and zero when it was produced without a spend.

    LiveTranslateSettings:
      type: object
      additionalProperties: false
      required: [enabled]
      description: >
        ATH-223 — LiveTranslate's per-conversation state (Doc 04 §9).
      properties:
        enabled: { type: boolean, description: Whether this thread is being translated. Default false on every conversation. }
        agent_locale:
          type: [string, "null"]
          maxLength: 24
          description: >
            The language the TEAM reads this thread in. Per conversation
            rather than per workspace because a workspace with a LATAM
            sub-inbox and a DACH sub-inbox does not have one team
            language, and the agent who flipped the toggle is the one
            who knows.
        visitor_locale:
          type: [string, "null"]
          maxLength: 24
          description: >
            The language most recently DETECTED from the customer, which
            is where the next outbound reply will be translated to. Null
            until the customer has written something — a stranger's
            language is not guessed. Read-only: it is set by detection,
            never by the client.

    CobrowseSession:
      type: object
      additionalProperties: false
      required: [id, conversation_id, status, channel, expires_at, consented_at, ended_at, ended_reason]
      description: >
        ATH-261 — a live co-browse session (Doc 04 §9). CONTROL-PLANE
        metadata ONLY: who may watch, whether the visitor consented, and
        when the window closes. The visitor's screen is never stored on
        this row — rrweb frames are relayed as ephemeral realtime events
        and dropped. This is the consent + lifecycle record, not a
        recording.
      properties:
        id: { $ref: "#/components/schemas/Ulid" }
        conversation_id: { $ref: "#/components/schemas/Ulid" }
        status:
          type: string
          enum: [pending, active, ended, declined, expired]
          description: >
            pending = agent asked, awaiting the visitor's explicit consent;
            active = visitor accepted and frames may stream; ended = a party
            stopped it; declined = visitor refused; expired = the bounded
            window elapsed. Frames stream only while active.
        channel:
          type: string
          description: >
            Wire channel name (private-conversation.{id}) the masked frames
            stream on — the SAME channel the conversation already uses, so
            co-browse reuses the widget's realtime transport and its
            visitor-token channel scoping rather than inventing a second one.
        consented_at:
          type: [string, "null"]
          format: date-time
          description: >
            When the visitor gave explicit consent. Null until accepted — a
            session with no consent timestamp has never streamed a frame.
        expires_at:
          type: string
          format: date-time
          description: >
            The bounded window. Streaming stops at this instant even if
            neither party ended it.
        ended_at: { type: [string, "null"], format: date-time }
        ended_reason:
          type: [string, "null"]
          description: >
            Why the session ended, when it has: one of agent_ended,
            visitor_ended, declined, or expired. Null while still live.

    CobrowseGuideCapture:
      type: object
      additionalProperties: false
      required: [id, cobrowse_session_id, status, requested_at, consented_at, revoked_at, capture_session_id]
      description: >
        PA-261 (Doc 07 §3 / cobrowse→guide, Option B 2 of 3) — the consent +
        link record for turning a LIVE co-browse session into an editable
        guide. This is a DISTINCT, explicit, revocable consent, SEPARATE from
        the co-browse VIEW consent (CobrowseSession.consented_at): an agent
        watching a screen may not silently record it into a guide. The
        masked, structured steps land in a normal CaptureSession
        (source=cobrowse) — never a screen recording; co-browse itself still
        persists no frames.
      properties:
        id: { $ref: "#/components/schemas/Ulid" }
        cobrowse_session_id: { $ref: "#/components/schemas/Ulid" }
        status:
          type: string
          enum: [requested, consented, revoked]
          description: >
            requested = the agent asked, awaiting the visitor's explicit
            guide-capture consent; consented = the visitor opted in and the
            recorder may ingest steps; revoked = the visitor withdrew and
            every further ingest is refused. Derived from the timestamps
            below, not a stored column.
        requested_at:
          type: string
          format: date-time
          description: When the agent initiated guide capture on the live session.
        consented_at:
          type: [string, "null"]
          format: date-time
          description: >
            When the visitor gave explicit, distinct guide-capture consent.
            Null until they opt in — a record with no consent timestamp has
            ingested nothing.
        revoked_at:
          type: [string, "null"]
          format: date-time
          description: >
            When the visitor withdrew consent. Non-null makes every further
            start/append refuse at once (fail closed).
        capture_session_id:
          description: >
            The CaptureSession (source=cobrowse) this record is bound to, set
            when the recorder starts. Null before the first start.
          oneOf:
            - { $ref: "#/components/schemas/Ulid" }
            - type: "null"

    VisitorDevice:
      type: object
      additionalProperties: false
      required: [id, platform, created_at]
      description: >
        ATH-263 (Doc 04 §2.3) — a mobile device a widget visitor
        registered for push notifications. The APNs/FCM token itself is
        DELIBERATELY ABSENT: it is a push credential, stored encrypted at
        rest and never returned to any client. This is the acknowledgement
        the SDK keeps so it can later unregister the device by id.
      properties:
        id: { $ref: "#/components/schemas/Ulid" }
        platform:
          type: string
          enum: [ios, android]
          description: ios → APNs, android → FCM.
        created_at: { type: string, format: date-time }

    CallSession:
      type: object
      additionalProperties: false
      required:
        [id, conversation_id, status, media, initiated_by, expires_at, consented_at, ended_at,
         ended_reason]
      description: >
        ATH-262 — an audio/video call on a conversation (Doc 04 §9).
        CONTROL-PLANE metadata ONLY: who asked, what media the visitor
        agreed to, when they agreed, and when the window closes. The
        media itself never traverses Cairn — it goes peer-to-SFU through
        LiveKit — and NOTHING is recorded, so there is no artifact for
        this row to point at. It is the consent + lifecycle record.


        The room name is deliberately absent: a client that is entitled
        to join receives it on the `CallToken`, alongside the signed grant
        that makes knowing it useful.
      properties:
        id: { $ref: "#/components/schemas/Ulid" }
        conversation_id: { $ref: "#/components/schemas/Ulid" }
        status:
          type: string
          enum: [pending, active, ended, declined, expired]
          description: >
            pending = an agent asked and the visitor has not answered (no
            token exists for anybody); active = consented, tokens may be
            minted; ended = a party hung up; declined = the visitor
            refused; expired = the bounded window elapsed.
        media:
          type: string
          enum: [audio, video]
          description: >
            What the call currently carries, and therefore what the
            tokens grant permission to publish. A visitor answering a
            video call as audio narrows this for BOTH participants.
        initiated_by:
          type: string
          enum: [agent, visitor]
          description: >
            Which side placed the call. `visitor` calls are consented at
            creation — pressing call on your own device with your own
            token is the consent — so they start `active`.
        consented_at:
          type: [string, "null"]
          format: date-time
          description: >
            When the visitor agreed. Null until they do, and no token is
            minted for either participant while it is null.
        expires_at:
          type: string
          format: date-time
          description: >
            The bounded window. The call stops being joinable at this
            instant even if neither party hung up, and no token may
            outlive it.
        ended_at: { type: [string, "null"], format: date-time }
        ended_reason:
          type: [string, "null"]
          description: >
            One of agent_ended, visitor_ended, declined, or expired. Null
            while still live.

    CallToken:
      type: object
      additionalProperties: false
      required: [token, url, room, identity, media, expires_at]
      description: >
        ATH-262 — a short-lived LiveKit join credential. The JWT is signed
        server-side with the LiveKit API secret, which never reaches any
        client; the token itself is scoped to exactly one room, one
        participant identity, and the minimum grants a call needs.


        It carries NO recording grant. Cairn does not record calls: a
        recorded support call is a durable pile of a customer's voice and
        likeness, and the honest mitigation for "we might leak the
        recording" is not to keep one. `roomRecord` and `recorder` are
        false on every token this API issues, and there is no egress path
        behind it.
      properties:
        token:
          type: string
          description: The signed JWT. Treat as a credential — it is a bearer for this room.
        url:
          type: string
          description: The LiveKit websocket URL to connect to.
        room:
          type: string
          description: >
            The one room this token admits its holder to. Room names embed
            the owning workspace, so a token minted in one tenant names a
            room no other tenant's participants are in.
        identity:
          type: string
          description: The participant identity the token is bound to (agent-<userId> or visitor-<visitorId>).
        media:
          type: string
          enum: [audio, video]
          description: >
            The sources the grant permits publishing: `audio` = microphone
            only, `video` = microphone and camera. Never screen share, and
            never more than the visitor consented to.
        expires_at:
          type: string
          format: date-time
          description: >
            When the token stops being accepted — a short window, and
            never later than the call session's own `expires_at`. Clients
            re-request rather than holding a long-lived credential.

    AgentAnswer:
      type: object
      required: [type, sources]
      description: ATH-161 — the AI agent's answer envelope, rendered by the widget (Doc 05 §4).
      properties:
        type:
          type: string
          enum: [text, handoff]
          description: text = grounded answer with citations; handoff = the agent declined and is routing to a human
        sources:
          type: array
          description: Citations backing a text answer (empty for handoff).
          items:
            type: object
            required: [kind, title]
            properties:
              kind: { type: string, enum: [article, guide, link], description: Drives the card the widget renders }
              ref: { type: [string, "null"], description: "document ref, e.g. article:{id} or guide:{id}" }
              title: { type: string }
              url: { type: [string, "null"], format: uri }
              slug: { type: [string, "null"], description: Article slug — lets the card open in the help tab }
        execution:
          oneOf: [{ $ref: "#/components/schemas/AutomationOffer" }, { type: "null" }]
          description: >
            PA-221 (Doc 07 §2.2) — the "do it for me" offer. Present only on a
            `text` answer that cites an executable (published) guide when the
            workspace has feature.automation.agent_offer active; null/absent
            otherwise. When present, the widget shows an execute affordance
            that starts Cairn.automation.run(guide_id, prefill).

    AutomationOffer:
      type: object
      required: [guide_id, prefill, needs_manual]
      description: >
        PA-221 (Doc 07 §2.2) — an offer to execute a cited guide, carrying the
        parameters the assistant pre-filled from the conversation.

        RULE 3: sensitive/masked params are NEVER extracted, proposed, or
        carried here — they only ever appear as ids in `needs_manual`, for
        PA-218's secure prompt to collect by hand. Every `prefill` value is a
        NON-sensitive proposal the user confirms or edits before it is used;
        it is never auto-submitted.
      properties:
        guide_id: { $ref: "#/components/schemas/Ulid" }
        prefill:
          type: array
          description: >
            Non-sensitive step params the assistant proposed from the
            conversation, keyed by the guide step they fill.
          items:
            type: object
            required: [step_id, value]
            properties:
              step_id: { $ref: "#/components/schemas/Ulid" }
              value:
                type: string
                description: The proposed value, shown pre-filled in the secure prompt for the user to confirm or edit.
        needs_manual:
          type: array
          description: >
            Guide step ids the user must fill by hand: every sensitive/masked
            param, plus any non-sensitive param the assistant could not
            confidently propose.
          items: { $ref: "#/components/schemas/Ulid" }

    CopilotRequest:
      type: object
      required: [mode]
      description: >
        ATH-220 — one payload per copilot mode (Doc 05 §7). `ask` and
        `draft` need a question or take the conversation as-is; `rewrite`
        transforms text the teammate already wrote and needs both `text`
        and `tone`.
      properties:
        mode:
          type: string
          enum: [ask, draft, rewrite]
          description: ask = answer a teammate's question; draft = propose a reply to the customer; rewrite = restyle the teammate's own text
        question:
          type: [string, "null"]
          maxLength: 2000
          description: Required for `ask`. Optional steer for `draft`; omitted it drafts against the latest customer message.
        text:
          type: [string, "null"]
          maxLength: 10000
          description: Required for `rewrite` — the teammate's draft to restyle.
        tone:
          type: string
          enum: [formal, friendly, shorten]
          description: Required for `rewrite`. Omitted entirely for the other modes.

    CopilotSuggestion:
      type: object
      additionalProperties: false
      required: [id, mode, text, sources, grounded, credit_cost_cents, confidence]
      description: >
        ATH-220 — a suggestion for a human to edit and send. Never
        delivered to a customer by this API. `sources` may cite
        internal-audience knowledge.
      properties:
        id: { $ref: "#/components/schemas/Ulid" }
        mode: { type: string, enum: [ask, draft, rewrite] }
        text:
          type: string
          description: The answer, the drafted reply, or the rewritten text — depending on mode.
        sources:
          type: array
          description: >
            Citations backing the suggestion, resolved from retrieval
            rather than from model output. Empty for `rewrite`, which
            retrieves nothing, and empty when nothing grounded the
            question.
          items:
            type: object
            required: [kind, title, audience]
            properties:
              kind: { type: string, enum: [article, guide, link] }
              ref: { type: [string, "null"], description: "document ref, e.g. article:{id} or guide:{id}" }
              title: { type: string }
              url: { type: [string, "null"], format: uri }
              slug: { type: [string, "null"] }
              audience:
                type: string
                enum: [public, customers, internal]
                description: >
                  Which audience the cited chunk belongs to. `internal`
                  marks material the customer-facing agent can never
                  retrieve — the UI badges it so a teammate does not
                  paste it verbatim to a customer.
        grounded:
          type: boolean
          description: >
            False when retrieval found nothing above the grounding
            floor. The copilot then declines rather than inventing an
            answer and no credits are spent.
        confidence:
          type: [number, "null"]
          format: float
          description: Similarity of the best retrieved chunk. Null for `rewrite`.
        credit_cost_cents:
          type: integer
          description: What this suggestion debited. Zero when ungrounded or on an unlimited plan.

    HelpCenterAnswerCitation:
      type: object
      additionalProperties: false
      required: [title, slug, url]
      description: >
        ATH-224 — one cited public article behind a site-search answer
        (Doc 05 §7). Only PUBLIC + PUBLISHED articles are ever citable,
        re-verified against the live article row after retrieval, so this
        shape cannot carry internal or draft content by construction.
      properties:
        title: { type: string }
        slug: { type: string, description: Article slug on this help-center host }
        url: { type: string, format: uri, description: Absolute URL on the requesting host (custom domains emit their own) }

    HelpCenterAnswer:
      type: object
      additionalProperties: false
      required: [status, answer, citations]
      description: >
        ATH-224 — the AI answer box on the public help centre (Doc 05 §7,
        Doc 03 §5).

        The answer is ADVISORY: the ordinary link results are rendered
        server-side by GET /search and never depend on this call. Every
        non-answering case is a 200 with a status, never an error — a
        public marketing surface must not show a visitor a 402 or a 404
        because the workspace ran out of AI credits.
      properties:
        status:
          type: string
          enum: [answered, no_answer, unavailable]
          description: >
            answered = grounded answer with citations; no_answer = nothing
            public covers the question, so the assistant refuses rather
            than inventing; unavailable = the answer box is switched off
            for this workspace (flag off, plan below the AI suite, credits
            exhausted, or generation failed). `unavailable` is deliberately
            ONE value: a visitor must not be able to tell a workspace's
            plan or credit balance apart from a disabled feature.
        answer:
          type: [string, "null"]
          description: The synthesized answer, or the refusal copy on no_answer. Null when unavailable.
        citations:
          type: array
          description: Public articles backing the answer. Always empty unless status is answered.
          items: { $ref: "#/components/schemas/HelpCenterAnswerCitation" }

    RealtimeResumeBatch:
      type: object
      additionalProperties: false
      required: [data, latest_cursor, complete, resync]
      properties:
        data:
          type: array
          items:
            type: object
            required: [cursor, name, payload, created_at]
            properties:
              cursor: { type: integer }
              name:
                type: string
                enum: [message.created, conversation.updated, conversation.assigned, conversation.state_changed]
                description: Durable catalog names only — ephemeral events never replay
              payload: { type: object }
              created_at: { type: string, format: date-time }
        latest_cursor: { type: [integer, "null"], description: Highest cursor currently in the ledger for this channel }
        complete: { type: boolean, description: False when a page limit was hit — call again with the last cursor }
        resync: { type: boolean, description: Continuity unprovable (cursor predates retention) — refetch state via REST }

    GuideShare:
      type: object
      additionalProperties: false
      required: [share_mode, share_indexable, public_id, url, embed_url]
      properties:
        share_mode: { type: string, enum: [private, unlisted, public] }
        share_indexable: { type: boolean }
        public_id: { type: [string, "null"] }
        url: { type: [string, "null"], description: Public share-page URL; null while private with no minted id }
        embed_url: { type: [string, "null"] }

    HelpCenterTheme:
      type: object
      additionalProperties: false
      required: [white_label, logo_media_id, header_links, custom_css]
      properties:
        logo_media_id: { type: [string, "null"] }
        header_links:
          type: array
          items:
            type: object
            required: [label, url]
            properties:
              label: { type: string, maxLength: 40 }
              url: { type: string, maxLength: 500 }
        custom_css: { type: [string, "null"] }
        white_label: { type: boolean }

    HelpCenterThemeInput:
      type: object
      properties:
        logo_media_id: { type: [string, "null"] }
        header_links:
          type: array
          maxItems: 8
          items:
            type: object
            required: [label, url]
            properties:
              label: { type: string, maxLength: 40 }
              url: { type: string, maxLength: 500 }
        custom_css: { type: [string, "null"], maxLength: 20000 }
        white_label: { type: boolean }

    Comment:
      type: object
      additionalProperties: false
      required: [id, body, created_at, step_id, author_id, resolved_at]
      properties:
        id: { $ref: "#/components/schemas/Ulid" }
        step_id: { type: [string, "null"] }
        body: { type: string }
        author_id: { type: [string, "null"] }
        resolved_at: { type: [string, "null"], format: date-time }
        created_at: { type: string, format: date-time }

    GuideLockState:
      type: object
      additionalProperties: false
      required: [data]
      properties:
        data:
          type: object
          required: [acquired]
          properties:
            acquired: { type: boolean }
            locked_by: { type: [string, "null"] }
            locked_by_name: { type: [string, "null"] }
            heartbeat_at: { type: [string, "null"], format: date-time }

    BrandProfile:
      type: object
      additionalProperties: false
      required: [id, name, primary_color, secondary_color, marker_style, is_default, font, logo_media_id]
      properties:
        id: { $ref: "#/components/schemas/Ulid" }
        name: { type: string }
        primary_color: { type: string, pattern: "^#[0-9a-fA-F]{6}$" }
        secondary_color: { type: string, pattern: "^#[0-9a-fA-F]{6}$" }
        marker_style: { type: string, enum: [ring, dot, badge] }
        font: { type: [string, "null"], description: CSS font stack }
        logo_media_id: { type: [string, "null"] }
        is_default: { type: boolean }

    BrandProfileInput:
      type: object
      properties:
        name: { type: string, maxLength: 120 }
        primary_color: { type: string, pattern: "^#[0-9a-fA-F]{6}$" }
        secondary_color: { type: string, pattern: "^#[0-9a-fA-F]{6}$" }
        marker_style: { type: string, enum: [ring, dot, badge] }
        font: { type: [string, "null"], maxLength: 120 }
        logo_media_id: { type: [string, "null"] }
        is_default: { type: boolean }

    OnboardingChecklist:
      type: object
      additionalProperties: false
      required: [steps, completed_count, total, dismissed]
      properties:
        steps:
          type: array
          items:
            type: object
            required: [key, title, description, cta, completed]
            properties:
              key: { type: string }
              title: { type: string }
              description: { type: string }
              cta: { type: string, description: "Portal path relative to /w/{workspace}" }
              completed: { type: boolean }
        completed_count: { type: integer, minimum: 0 }
        total: { type: integer, minimum: 0 }
        dismissed: { type: boolean }

    CapturePolicy:
      type: object
      additionalProperties: false
      required: [blocked_domains, enforce_masking, enforced_rules, policy_checksum]
      properties:
        blocked_domains:
          type: array
          items: { type: string }
          description: Never-record domains; supports "*.example.com" wildcards
        enforce_masking:
          type: boolean
          description: When true, clients must not offer per-field unmask whitelisting
        enforced_rules:
          type: array
          description: >
            ATH-235 — admin-defined redaction policy rules (Doc 02 §7.3–7.4).
            Always [] below the Enterprise plan (a downgrade revokes without
            a write). Rules with enforced=true are org policy: applied
            automatically at ingest, before review, and authors cannot
            remove the resulting regions.
          items: { $ref: "#/components/schemas/RedactionPolicyRule" }
        policy_checksum:
          type: [string, "null"]
          description: >
            sha256 over the canonical enforced rule set — recorded on every
            guide at ingest as compliance evidence (Doc 02 §7.3). Null when
            the plan lacks feature.security.enforced_redaction.

    RedactionRuleKind:
      type: string
      description: >
        category = auto-blur a Smart Blur detector category wherever the
        Doc 02 §7.2 bank finds it (placement lands with ATH-234); selector =
        blur the bounding box of any captured element whose recorded
        selector_candidates contain the pattern; field_label = blur any
        captured element whose accessible name/label matches the pattern as
        a case-insensitive regex — "blur any field labeled Patient"
        (Doc 02 §7.4).
      enum: [category, selector, field_label]

    RedactionCategory:
      type: string
      description: PII/secret categories from the Doc 02 §7.2 detector bank
      enum: [pii.email, pii.phone, pii.ssn, pii.card, pii.iban, pii.name, pii.address, secret.api_key]

    RedactionPolicyRule:
      type: object
      additionalProperties: false
      required: [id, kind, pattern, category, enforced, created_at]
      properties:
        id: { $ref: "#/components/schemas/Ulid" }
        kind: { $ref: "#/components/schemas/RedactionRuleKind" }
        pattern:
          type: [string, "null"]
          description: Selector or label regex (no delimiters); null for category rules
        category:
          oneOf:
            - $ref: "#/components/schemas/RedactionCategory"
            - type: "null"
        enforced:
          type: boolean
          description: >
            true = org policy — ingest creates source=enforced regions,
            applied before any human sees the screenshot and non-removable
            by authors; false = suggestion — regions land as
            source=assisted, status=pending_review for editor review.
        created_at: { type: string, format: date-time }

    RedactionPolicyRuleInput:
      type: object
      required: [kind]
      properties:
        kind: { $ref: "#/components/schemas/RedactionRuleKind" }
        pattern:
          type: [string, "null"]
          maxLength: 500
          description: Required for selector/field_label kinds; must compile as a regex for field_label
        category:
          oneOf:
            - $ref: "#/components/schemas/RedactionCategory"
            - type: "null"
          description: Required for the category kind
        enforced: { type: boolean, default: true }

    Audience:
      type: object
      additionalProperties: false
      description: >
        PA-231 — a retrieval + governance audience (Doc 07 §5). The fixed
        trio (public, customers, internal) ships as system rows per
        workspace; Enterprise workspaces add custom audiences. Every
        audience resolves to one of the three retrieval SCOPES that
        knowledge_chunks are partitioned by and HybridRetriever enforces —
        a custom audience is a governance label over a base scope, never a
        fourth retrieval partition. Unknown/misconfigured fails closed to
        `internal`.
      required: [id, key, name, is_system, retrieval_scope, created_at, updated_at]
      properties:
        id: { $ref: "#/components/schemas/Ulid" }
        key:
          type: string
          description: Stable slug; the trio is public|customers|internal.
        name: { type: string }
        is_system:
          type: boolean
          description: True for the fixed trio; system rows cannot be deleted or re-scoped.
        retrieval_scope:
          type: string
          enum: [public, customers, internal]
          description: >
            The knowledge_chunks partition this audience reads. For the trio
            it equals `key`; for a custom audience it is the base scope it
            inherits. Fails closed to `internal`.
        created_at: { type: [string, "null"], format: date-time }
        updated_at: { type: [string, "null"], format: date-time }

    AudienceInput:
      type: object
      required: [key, name, retrieval_scope]
      properties:
        key:
          type: string
          minLength: 2
          maxLength: 32
          pattern: "^[a-z][a-z0-9_]*$"
          description: Stable slug; may not be one of the reserved trio keys.
        name: { type: string, minLength: 1, maxLength: 80 }
        retrieval_scope:
          type: string
          enum: [public, customers, internal]
          description: The base scope a custom audience inherits; fails closed to internal.

    ContentAudiencePublication:
      type: object
      additionalProperties: false
      description: >
        PA-231 — one (content × audience) publication with its per-audience
        variant (Doc 07 §5): the redaction PROFILE (the Doc 02 §7 categories
        this audience's media variant blurs at render — the public variant
        blurs what the internal one keeps), a per-audience freshness SLA
        (`verify_interval_days`), and documented locale/theme SEAMS.
        `published_at` set means the content is live to that audience;
        retrieval and media serving both honour it.
      required:
        [id, content_type, content_id, audience_id, redaction_profile, published, created_at,
         verify_interval_days, locale_set, theme, published_at, updated_at]
      properties:
        id: { $ref: "#/components/schemas/Ulid" }
        content_type: { type: string, enum: [guide, article] }
        content_id: { $ref: "#/components/schemas/Ulid" }
        audience_id: { $ref: "#/components/schemas/Ulid" }
        redaction_profile:
          type: array
          description: >
            Doc 02 §7.2 categories this audience's media variant auto-blurs
            at render time. Empty = full fidelity (what internal keeps).
          items: { $ref: "#/components/schemas/RedactionCategory" }
        verify_interval_days:
          type: [integer, "null"]
          description: Per-audience review cadence (Doc 03 §2.1); null inherits the content-level schedule.
        locale_set:
          type: [array, "null"]
          description: SEAM (deferred) — the locales this audience publishes; not yet wired to translation.
          items: { type: string }
        theme:
          type: [object, "null"]
          description: SEAM (deferred) — per-audience theming; not yet wired to the help-center renderer.
        published:
          type: boolean
          description: Whether the content is currently live to this audience.
        published_at: { type: [string, "null"], format: date-time }
        created_at: { type: [string, "null"], format: date-time }
        updated_at: { type: [string, "null"], format: date-time }

    ContentAudiencePublicationInput:
      type: object
      properties:
        redaction_profile:
          type: array
          items: { $ref: "#/components/schemas/RedactionCategory" }
          default: []
        verify_interval_days:
          type: [integer, "null"]
          minimum: 1
          maximum: 3650
        locale_set:
          type: [array, "null"]
          items: { type: string }
        theme:
          type: [object, "null"]
        published:
          type: boolean
          default: true
          description: false unpublishes from the audience without deleting the row.

    GovernanceMatrixAudience:
      type: object
      additionalProperties: false
      description: PA-232 — one column of the governance matrix.
      required: [audience_id, key, name, is_system, retrieval_scope]
      properties:
        audience_id: { $ref: "#/components/schemas/Ulid" }
        key: { type: string }
        name: { type: string }
        is_system: { type: boolean }
        retrieval_scope: { type: string, enum: [public, customers, internal] }

    GovernanceMatrixCell:
      type: object
      additionalProperties: false
      description: >
        PA-232 — one (content × audience) cell. `published` is the governance
        publish state; the content's own lifecycle is the row's `status`.
      required:
        [audience_id, published, redaction_profile, verification_status, translation_freshness,
         verify_interval_days]
      properties:
        audience_id: { $ref: "#/components/schemas/Ulid" }
        published: { type: boolean }
        redaction_profile:
          type: array
          description: The Doc 02 §7 categories this audience's media variant blurs; empty = full fidelity.
          items: { $ref: "#/components/schemas/RedactionCategory" }
        verify_interval_days:
          type: [integer, "null"]
          description: The per-audience review cadence override, when set.
        verification_status:
          type: string
          enum: [verified, due, expired, none]
          description: >
            Freshness of this cell computed against the per-audience
            `verify_interval_days` override (falling back to the content-level
            schedule); `none` when the content is on no review cadence.
        translation_freshness:
          type: string
          enum: [fresh, stale, none, n_a]
          description: >
            `stale` when a published translation's source hash no longer
            matches the article body (Doc 03 §5); `none` when the article has
            no translations; `n_a` for guides (no per-locale variants yet).

    GovernanceMatrixRow:
      type: object
      additionalProperties: false
      description: PA-232 — one content item and its per-audience cells.
      required: [content_type, content_id, title, status, verification_status, cells]
      properties:
        content_type: { type: string, enum: [guide, article] }
        content_id: { $ref: "#/components/schemas/Ulid" }
        title: { type: string }
        status:
          type: [string, "null"]
          description: The content's own lifecycle status (draft/published/archived), or null if it was deleted.
        verification_status:
          type: string
          enum: [verified, due, expired, none]
          description: The content-level review status (before any per-audience override).
        cells:
          type: array
          items: { $ref: "#/components/schemas/GovernanceMatrixCell" }

    BulkAudiencePublishOperation:
      type: object
      required: [content_type, content_id, audience_id]
      properties:
        content_type: { type: string, enum: [guide, article] }
        content_id: { $ref: "#/components/schemas/Ulid" }
        audience_id: { $ref: "#/components/schemas/Ulid" }
        published:
          type: boolean
          default: true
          description: true publishes to the audience; false unpublishes without deleting the row.
        redaction_profile:
          type: array
          items: { $ref: "#/components/schemas/RedactionCategory" }
        verify_interval_days:
          type: [integer, "null"]
          minimum: 1
          maximum: 3650

    BulkAudiencePublishInput:
      type: object
      required: [operations]
      properties:
        operations:
          type: array
          minItems: 1
          maxItems: 100
          items: { $ref: "#/components/schemas/BulkAudiencePublishOperation" }

    BulkAudiencePublishItemResult:
      type: object
      additionalProperties: false
      required: [content_type, content_id, audience_id, ok, status, error, message]
      properties:
        content_type: { type: string, enum: [guide, article] }
        content_id: { $ref: "#/components/schemas/Ulid" }
        audience_id: { $ref: "#/components/schemas/Ulid" }
        ok: { type: boolean }
        status:
          type: string
          enum: [published, unpublished, failed]
        error:
          type: [string, "null"]
          description: Stable code when ok=false (content_not_found, audience_not_found, public_policy).
        message: { type: [string, "null"] }

    BulkAudiencePublishResult:
      type: object
      additionalProperties: false
      required: [results]
      properties:
        results:
          type: array
          items: { $ref: "#/components/schemas/BulkAudiencePublishItemResult" }

    GovernanceAuditEntry:
      type: object
      additionalProperties: false
      description: PA-232 — one audience-change row from the append-only audit trail.
      required: [id, action, actor_type, created_at]
      properties:
        id: { $ref: "#/components/schemas/Ulid" }
        action:
          type: string
          description: e.g. audience.published, audience.unpublished, audience.variant_updated, audience.public_approval_set.
        actor_type: { type: string, enum: [user, super_admin, system, api_key] }
        actor_id: { type: [string, "null"] }
        content_type: { type: [string, "null"] }
        content_id:
          oneOf:
            - $ref: "#/components/schemas/Ulid"
            - type: "null"
        audience_id:
          oneOf:
            - $ref: "#/components/schemas/Ulid"
            - type: "null"
        audience_key: { type: [string, "null"] }
        metadata:
          type: object
          additionalProperties: true
          description: Before/after variant fields and the resolved content/audience keys.
        created_at: { type: [string, "null"], format: date-time }

    PublicationApproval:
      type: object
      additionalProperties: false
      description: >
        PA-232 — the `public`-publish clearance for one content item (Doc 07
        §5 Enterprise policy). `cleared` is true only when a redaction scan
        pass is recorded AND a content_manager approval is present.
      required:
        [content_type, content_id, redaction_scan_passed, approved, cleared, created_at,
         redaction_scan_at, approved_by, approved_at, updated_at]
      properties:
        content_type: { type: string, enum: [guide, article] }
        content_id: { $ref: "#/components/schemas/Ulid" }
        redaction_scan_passed: { type: boolean }
        redaction_scan_at: { type: [string, "null"], format: date-time }
        approved:
          type: boolean
          description: Whether a content_manager approval is currently on record.
        approved_by: { type: [string, "null"] }
        approved_at: { type: [string, "null"], format: date-time }
        cleared:
          type: boolean
          description: redaction_scan_passed AND approved — the whole gate.
        created_at: { type: [string, "null"], format: date-time }
        updated_at: { type: [string, "null"], format: date-time }

    PublicationApprovalInput:
      type: object
      properties:
        approved:
          type: boolean
          description: true records the acting content_manager as approver; false withdraws.
        redaction_scan_passed:
          type: boolean
          description: Records (or clears) the redaction-scan-pass signal; PA-233 will own this.

    MediaAsset:
      type: object
      additionalProperties: false
      required: [id, kind, width, height, bytes, sha256, created_at]
      properties:
        id: { $ref: "#/components/schemas/Ulid" }
        kind: { type: string, enum: [screenshot, audio] }
        width: { type: [integer, "null"] }
        height: { type: [integer, "null"] }
        bytes: { type: [integer, "null"] }
        sha256: { type: [string, "null"] }
        created_at: { type: string, format: date-time }

    MediaOriginalState:
      type: object
      additionalProperties: false
      description: PA-279 — irreversible lifecycle state of one screenshot original.
      required: [media_id, encrypted, deleted_at, deleted_by]
      properties:
        media_id: { $ref: "#/components/schemas/Ulid" }
        encrypted:
          type: boolean
          description: True when the original was application-encrypted before deletion.
        deleted_at: { type: [string, "null"], format: date-time }
        deleted_by:
          oneOf:
            - { $ref: "#/components/schemas/Ulid" }
            - type: "null"

    MobileUploadLink:
      type: object
      additionalProperties: false
      required: [url, expires_at]
      description: >
        PA-184 (Doc 02 §5) — the QR/link payload returned to an authoring
        member. `url` is a Laravel signed URL scoped to one capture session;
        opening it on a phone serves the mobile upload page until expires_at.
      properties:
        url:
          type: string
          format: uri
          description: Short-lived signed URL the phone opens to add a screenshot (encode as a QR).
        expires_at: { type: string, format: date-time }

    AdminWorkspace:
      allOf:
        - $ref: "#/components/schemas/Workspace"
        - type: object
          required: [suspended_at, deleted_at, created_at, members_count, ai_credit_balance_cents]
          properties:
            suspended_at: { type: [string, "null"], format: date-time }
            deleted_at: { type: [string, "null"], format: date-time }
            created_at: { type: string, format: date-time }
            members_count: { type: integer }
            ai_credit_balance_cents: { type: integer }

    InvitationPreview:
      type: object
      additionalProperties: false
      required: [workspace_name, email, role, expires_at]
      properties:
        workspace_name: { type: string }
        email: { type: string, format: email }
        role: { $ref: "#/components/schemas/Role" }
        expires_at: { type: string, format: date-time }

    ApiKey:
      type: object
      additionalProperties: false
      required: [id, name, abilities, key_prefix, last_used_at, expires_at, revoked_at]
      properties:
        id: { $ref: "#/components/schemas/Ulid" }
        name: { type: string }
        abilities: { type: array, items: { type: string } }
        key_prefix:
          type: string
          description: >
            First 12 characters of the plaintext ("crn_k1_ab12") — the
            only way to match a row to a key a human still holds. Not a
            secret and not enough to authenticate with.
        last_used_at: { type: [string, "null"], format: date-time }
        expires_at: { type: [string, "null"], format: date-time }
        revoked_at:
          type: [string, "null"]
          format: date-time
          description: ATH-250 — non-null means the key is dead; requests with it get 401.

    OAuthApp:
      type: object
      additionalProperties: false
      required:
        [id, name, client_id, redirect_uris, requested_abilities, description,
         client_secret_prefix, created_at]
      properties:
        id: { $ref: "#/components/schemas/Ulid" }
        name: { type: string }
        description: { type: [string, "null"] }
        client_id:
          type: string
          description: >
            Public client identifier. Not a secret — it travels in the
            authorize URL — and not sufficient to exchange a code.
        redirect_uris:
          type: array
          items: { type: string, format: uri }
          description: >
            Matched EXACTLY at authorize time. No prefix match, no
            wildcard, no trailing-slash forgiveness.
        requested_abilities:
          type: array
          items: { type: string }
          description: >
            The app's ceiling, in ATH-250 ability terms. A consent may
            grant a subset of this and never more.
        client_secret_prefix:
          type: string
          description: >
            First 12 characters of the client secret — how a human
            matches a row to the value in their password manager. The
            secret itself is stored only as a sha256 digest.
        created_at: { type: string, format: date-time }

    OAuthAppInstall:
      type: object
      additionalProperties: false
      required: [id, app, granted_abilities]
      properties:
        id: { $ref: "#/components/schemas/Ulid" }
        app: { $ref: "#/components/schemas/OAuthApp" }
        granted_abilities:
          type: array
          items: { type: string }
          description: >
            What this workspace actually granted — the intersection of
            the app's request with what the granting user could delegate,
            never the app's full request by default.
        granted_permissions:
          type: array
          items: { type: string }
          description: >
            The same grant restated in RBAC catalog permissions
            (`ApiAbilities::permissionsFor`), which is the language the
            roles UI speaks and the consent screen renders.
        granted_by_user_id: { $ref: "#/components/schemas/Ulid" }
        granted_at: { type: string, format: date-time }
        revoked_at:
          type: [string, "null"]
          format: date-time
          description: Non-null means every token this install issued is dead.

    OAuthTokenRequest:
      type: object
      required: [grant_type, client_id]
      properties:
        grant_type:
          type: string
          enum: [authorization_code, refresh_token]
        client_id: { type: string }
        client_secret: { type: string }
        code:
          type: string
          description: Authorization-code grant only. Single use, short TTL.
        redirect_uri:
          type: string
          format: uri
          description: >
            Authorization-code grant only. Must equal the URI the code
            was issued against, exactly.
        code_verifier:
          type: string
          description: >
            Authorization-code grant only, and MANDATORY — the PKCE
            verifier whose S256 hash must equal the stored challenge.
        refresh_token:
          type: string
          description: Refresh grant only. Rotated on every use.

    OAuthTokenResponse:
      type: object
      additionalProperties: false
      required: [access_token, token_type, expires_in, scope, workspace_id, refresh_token]
      properties:
        access_token: { type: string }
        token_type: { type: string, enum: [Bearer] }
        expires_in: { type: integer }
        refresh_token: { type: string }
        scope:
          type: string
          description: >
            Space-delimited granted abilities (RFC 6749 §5.1). The app
            is told what it holds at issue time, in the same vocabulary
            the resource endpoints enforce, so learning its own
            capabilities never requires a second call. ATH-253 adds
            `/oauth/introspect` for the different question a long-lived
            connection asks later — "is this still live?" — which issue
            time cannot answer.
        workspace_id:
          allOf: [{ $ref: "#/components/schemas/Ulid" }]
          description: >
            The tenant this token acts within. Also handed over at issue
            time rather than through a lookup, for the same reason.

    OAuthError:
      type: object
      additionalProperties: false
      required: [error, error_description]
      properties:
        error:
          type: string
          description: >
            RFC 6749 §5.2 code. Deliberately a flat string rather than
            the house `error.code` envelope so that stock OAuth client
            libraries can parse it.
          enum:
            - invalid_request
            - invalid_client
            - invalid_grant
            - unauthorized_client
            - unsupported_grant_type
            - invalid_scope
        error_description: { type: string }

    OAuthIntrospectionRequest:
      type: object
      description: RFC 7662 §2.1. Client authentication travels in the body, as at `/oauth/token`.
      required: [token, client_id, client_secret]
      properties:
        token: { type: string, description: The access token being asked about. }
        token_type_hint:
          type: string
          enum: [access_token]
          description: >
            RFC 7662 §2.1 permits this hint and permits a server to
            ignore it. Cairn introspects access tokens only; a refresh
            token presented here is inactive, because a client that can
            still refresh does not need to ask.
        client_id: { type: string }
        client_secret: { type: string }

    OAuthIntrospection:
      type: object
      additionalProperties: false
      description: >
        RFC 7662 §2.2. `active` is the only guaranteed member: when it
        is false every other field is ABSENT, so this endpoint reveals
        nothing about tokens the calling client was not issued.
      required: [active]
      properties:
        active:
          type: boolean
          description: >
            True only when the token is well-formed, unexpired,
            unrevoked, belongs to a live install, and was issued to the
            authenticating client. Branch on this, never on the status
            code — an inactive token is a 200.
        scope:
          type: string
          description: Space-delimited abilities, the same string `/oauth/token` returned.
        client_id: { type: string }
        token_type: { type: string, enum: [Bearer] }
        exp:
          type: integer
          description: Expiry as seconds since the epoch (RFC 7662 §2.2).
        iat:
          type: integer
          description: Issued-at as seconds since the epoch.
        workspace_id:
          allOf: [{ $ref: "#/components/schemas/Ulid" }]
          description: The tenant this token acts within.
        workspace_name:
          type: string
          description: >
            Cairn extension, not RFC 7662. An iPaaS connection list
            shows one row per connected account, and "Acme Support" is
            the only thing that lets a user tell two of their own
            workspaces apart; a ULID does not. Disclosed only to a
            client that already holds a live token for that workspace,
            so it reveals nothing the caller cannot already read.

    AppContext:
      type: object
      required: [url, title]
      properties:
        url: { type: string, format: uri }
        title: { type: string }
        viewport:
          type: object
          properties:
            w: { type: integer }
            h: { type: integer }

    CaptureSession:
      type: object
      additionalProperties: false
      required: [id, source, status, app_context, started_at]
      properties:
        id: { $ref: "#/components/schemas/Ulid" }
        source:
          type: string
          enum: [extension, desktop, upload, cobrowse]
          description: >
            Where the recording came from. `cobrowse` (PA-261, Doc 07 §3) is
            a visitor-produced recording opened from a consented, live
            co-browse session — masked, structured steps like any authored
            capture, never a screen recording, and its interaction steps
            carry element metadata (rule 4) exactly like an extension web
            capture.
        status: { type: string, enum: [recording, finalizing, finalized] }
        app_context:
          description: >
            PA-324 — null until the recorder reports it, and permanently null
            for sources that have no page to describe: a desktop or upload
            capture has no URL or title. The column is nullable and the key is
            always present, so this stays required — required means the key is
            there, not that it carries a value.
          oneOf:
            - { $ref: "#/components/schemas/AppContext" }
            - { type: "null" }
        started_at: { type: string, format: date-time }

    CaptureStep:
      type: object
      required: [seq, type, ts]
      properties:
        seq: { type: integer, minimum: 1 }
        type:
          type: string
          enum: [click, dblclick, type, select, navigate, scroll, keypress, drag, manual_note]
        ts: { type: integer, description: Epoch ms }
        screenshot_ref:
          type: [string, "null"]
          description: Media upload reference returned by the media endpoint
        click:
          type: [object, "null"]
          properties:
            x: { type: integer }
            y: { type: integer }
        element: { $ref: "#/components/schemas/ElementMeta" }
        input:
          type: [object, "null"]
          description: Typed input metadata — raw values are NEVER transmitted for masked fields
          properties:
            masked: { type: boolean }
            label: { type: [string, "null"] }
        url: { type: [string, "null"], format: uri }
        voice_transcript_span:
          type: [array, "null"]
          items: { type: number }
          minItems: 2
          maxItems: 2

    ElementMeta:
      type: [object, "null"]
      description: >
        Structured element data — the linchpin for walkthroughs & drift
        detection (doc 02 §2).


        DELIBERATELY SHARED between request and response. Capture SENDS this
        shape and guides RETURN it, unchanged; it is a value object, not a
        settings bag, so one `required` list is correct for both directions.
        See PA-322.


        `required` is what the server was observed to always send across the
        suite. `role`, `tag` and `bounding_box` are genuinely conditional —
        an AX-sourced element has no tag, and a headless capture has no box.
      required: [selector_candidates, name, source]
      properties:
        selector_candidates:
          type: array
          items: { type: string }
          description: Ranked; 3–5 entries (id, data-test, role+name, css path, text)
        role: { type: [string, "null"] }
        name: { type: [string, "null"] }
        tag: { type: [string, "null"] }
        bounding_box:
          type: [object, "null"]
          properties:
            x: { type: integer }
            y: { type: integer }
            w: { type: integer }
            h: { type: integer }
        dom_path_hash: { type: [string, "null"] }
        nearest_landmark: { type: [string, "null"] }
        source: { type: string, enum: [dom, ax], default: dom }

    GuideStep:
      type: object
      required:
        [id, seq, type, text, screenshot, redactions, policy_redactions, smart_blur_suggestions,
         annotations, element, url, alerts, walkthrough, input]
      properties:
        id: { $ref: "#/components/schemas/Ulid" }
        seq: { type: integer }
        type: { type: string }
        text: { type: string }
        screenshot:
          type: [object, "null"]
          properties:
            id: { $ref: "#/components/schemas/Ulid" }
            media_id:
              allOf: [{ $ref: "#/components/schemas/Ulid" }]
              description: PA-279 — stable id used by the strict-original deletion endpoint.
            url: { type: string, format: uri, description: "Signed, expiring URL" }
            width: { type: integer }
            height: { type: integer }
            original_encrypted:
              type: boolean
              description: PA-279 — true when the stored raw object uses application encryption.
            original_deleted_at:
              type: [string, "null"]
              format: date-time
              description: PA-279 — non-null once the raw object and unredacted derivatives are permanently gone.
        redactions:
          type: array
          description: Manual blur regions (normalized 0..1 rects) — the editor round-trips these (ATH-082)
          items:
            type: object
            properties:
              x: { type: number }
              y: { type: number }
              w: { type: number }
              h: { type: number }
        policy_redactions:
          type: array
          description: >
            ATH-235 — read-only blur regions originating from workspace
            redaction policy (source=enforced). The editor renders these
            locked; step.set_redactions cannot create, move, or remove them.
          items:
            type: object
            properties:
              x: { type: number }
              y: { type: number }
              w: { type: number }
              h: { type: number }
        smart_blur_suggestions:
          type: array
          description: >
            PA-277 — assisted regions with durable accept/dismiss status.
            The editor reviews these in bulk; no OCR text or detected PII
            leaves the server.
          items: { $ref: "#/components/schemas/SmartBlurSuggestion" }
        annotations: { type: [object, "null"] }
        element: { $ref: "#/components/schemas/ElementMeta" }
        url: { type: [string, "null"], format: uri }
        alerts: { type: [array, "null"], items: { type: object } }
        walkthrough:
          type: [object, "null"]
          description: >
            ATH-273 — per-step author controls for the walkthrough player
            (Doc 07 §2.1). Null means the defaults: walkthroughable, the
            written step text, no input placeholder.
          properties:
            enabled:
              type: boolean
              default: true
              description: False ⇒ the player's cursor skips this step (never shown, never counted).
            alternate_text:
              type: [string, "null"]
              description: Spoken-style instruction shown in the spotlight tooltip instead of the written step text.
            input_placeholder:
              type: [string, "null"]
              description: Hint rendered in the type-step affordance — capture is masked, so the author supplies it.
        input:
          type: [object, "null"]
          description: >
            PA-267 — the AUTHORITATIVE masked-input signal, carried across
            from CaptureStep.input at assembly and served identically on
            the portal and widget guide reads. Rule 3: this is a boolean
            ABOUT an input, never anything derived from its value — the
            capture contract's `value_hash` and `label` are deliberately
            NOT serialized here (a hash of a low-entropy value such as a
            postcode or card fragment is offline-guessable, and the
            player's human hint is the author-controlled
            `walkthrough.input_placeholder` above). Null means UNKNOWN,
            not "unmasked": guides assembled before PA-267 carry no flag,
            and the walkthrough runtime falls back to its DOM sensitivity
            heuristics for those.
          required: [masked]
          properties:
            masked:
              type: boolean
              description: >
                True ⇒ capture masked this step's input, so no value was
                ever recorded and the runtime must hand the field to the
                user (sensitive_manual) instead of typing into it. False
                ⇒ capture did not mask it; the runtime's DOM heuristics
                still apply and can still refuse on their own.

    GuideSummary:
      type: object
      required: [id, title, status, description, step_count, language, verified_at, created_at, updated_at]
      properties:
        id: { $ref: "#/components/schemas/Ulid" }
        title: { type: string }
        description: { type: [string, "null"] }
        status: { type: string, enum: [draft, in_review, published, archived] }
        step_count: { type: integer }
        language: { type: string }
        verified_at: { type: [string, "null"], format: date-time }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    Guide:
      allOf:
        - $ref: "#/components/schemas/GuideSummary"
        - type: object
          required: [steps]
          properties:
            steps:
              type: array
              items: { $ref: "#/components/schemas/GuideStep" }
            brand_profile_id: { type: [string, "null"] }
            brand:
              type: object
              description: Resolved brand (guide's profile → workspace default → platform fallback) — clients style markers/fonts at DISPLAY time so brand changes never reprocess assets (ATH-083)
              required: [primary_color, secondary_color, marker_style]
              properties:
                primary_color: { type: string }
                secondary_color: { type: string }
                marker_style: { type: string, enum: [ring, dot, badge] }
                font: { type: [string, "null"] }
                brand_profile_id: { type: [string, "null"] }
            capture_session_id: { type: [string, "null"] }

    WorkflowNode:
      type: object
      description: >
        ATH-191 — one graph node. `id` is author-assigned and unique in
        the graph; `type` selects the runtime NodeType; `config` is that
        node type's validated config. `position` is editor-only canvas
        geometry — the runtime ignores it, but it round-trips through the
        stored graph.
      required: [id, type]
      properties:
        id: { type: string, maxLength: 64 }
        type: { type: string }
        config:
          type: object
          additionalProperties: true
        position:
          type: object
          required: [x, y]
          properties:
            x: { type: number }
            y: { type: number }

    WorkflowEdge:
      type: object
      description: >
        ATH-191 — a directed edge. `condition` labels a branch: a
        condition node advancing on "true" follows the edge whose
        condition is "true"; a null-condition edge is the default advance.
      required: [from, to]
      properties:
        from: { type: string, maxLength: 64 }
        to: { type: string, maxLength: 64 }
        condition: { type: [string, "null"] }

    WorkflowGraph:
      type: object
      description: "ATH-191 — the authored graph: nodes + edges."
      required: [nodes, edges]
      properties:
        nodes:
          type: array
          items: { $ref: "#/components/schemas/WorkflowNode" }
        edges:
          type: array
          items: { $ref: "#/components/schemas/WorkflowEdge" }

    WorkflowTrigger:
      type: object
      description: >
        ATH-191 — what starts the workflow (Doc 05 §6). `event` is a
        domain-event name (e.g. conversation.message_received); `filters`
        narrow it (the runtime understands `keyword` today).
      properties:
        event: { type: [string, "null"] }
        filters:
          type: object
          additionalProperties: true

    WorkflowSummary:
      type: object
      description: ATH-191 — list-row shape; graphs omitted.
      required: [id, workspace_id, name, status, version, node_count, trigger_event, published, updated_at]
      properties:
        id: { $ref: "#/components/schemas/Ulid" }
        workspace_id: { $ref: "#/components/schemas/Ulid" }
        name: { type: string }
        status: { type: string, enum: [draft, published, archived] }
        version: { type: integer }
        node_count: { type: integer }
        trigger_event: { type: [string, "null"] }
        published: { type: boolean, description: True when status is published (the runtime triggers it) }
        updated_at: { type: string, format: date-time }

    Workflow:
      allOf:
        - $ref: "#/components/schemas/WorkflowSummary"
        - type: object
          description: >
            Full workflow. `graph` is the ACTIVE (published) graph the
            runtime reads — null until first publish; `draft_graph` is the
            work-in-progress the editor writes.
          properties:
            trigger:
              oneOf:
                - $ref: "#/components/schemas/WorkflowTrigger"
                - type: "null"
            graph:
              oneOf:
                - $ref: "#/components/schemas/WorkflowGraph"
                - type: "null"
            draft_graph:
              oneOf:
                - $ref: "#/components/schemas/WorkflowGraph"
                - type: "null"
            created_at: { type: string, format: date-time }

    WorkflowTemplatePlaceholder:
      type: object
      additionalProperties: false
      description: >
        ATH-194 — one value a workspace must supply before a template's
        graph means anything. Every placeholder is a STRING: it is
        substituted into `{{ placeholder.<key> }}` tokens in the template's
        message copy and trigger filters. Numbers that shape behaviour
        (delay lengths) are baked into the template and edited on the
        canvas afterwards, so a substitution can never turn an integer
        config field into a string.
      required: [key, label, required, help, kind, default, max_length]
      properties:
        key: { type: string, maxLength: 64 }
        label: { type: string }
        help: { type: [string, "null"] }
        kind:
          type: string
          enum: [text, textarea, segment, user, tool]
          description: >
            A hint for the form control. `segment`, `user` and `tool` name
            an id or slug the workspace already owns; the others are free
            text.
        required: { type: boolean }
        default: { type: [string, "null"] }
        max_length: { type: [integer, "null"] }

    WorkflowTemplate:
      type: object
      additionalProperties: false
      description: ATH-194 — one pre-built workflow a workspace can instantiate.
      required: [id, name, description, category, trigger_event, node_count, placeholders, default_name]
      properties:
        id: { type: string, maxLength: 64, description: Stable slug — the templateId path parameter. }
        name: { type: string }
        description: { type: string }
        category:
          type: string
          enum: [sales, support, retention, onboarding]
        trigger_event:
          type: string
          description: >
            The domain event the instantiated workflow triggers on. Always
            an event that implements TriggersWorkflows (ATH-019) — a
            shipped guard fails the build otherwise.
        node_count: { type: integer }
        default_name: { type: string, description: The workflow name used when the caller supplies none. }
        placeholders:
          type: array
          items: { $ref: "#/components/schemas/WorkflowTemplatePlaceholder" }

    WorkflowTemplateInput:
      type: object
      description: ATH-194 — instantiate payload.
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 120
          description: Overrides the template's default_name.
        placeholders:
          type: object
          description: Placeholder key to value. Every value is a string.
          additionalProperties: { type: string }

    WorkflowInput:
      type: object
      description: ATH-191 — create payload. Graph optional (a starter is seeded).
      required: [name]
      properties:
        name: { type: string, minLength: 1, maxLength: 120 }
        trigger: { $ref: "#/components/schemas/WorkflowTrigger" }
        graph: { $ref: "#/components/schemas/WorkflowGraph" }

    WorkflowUpdate:
      type: object
      description: >
        ATH-191 — partial draft update. A supplied `graph` replaces the
        DRAFT graph and is validated server-side.
      properties:
        name: { type: string, minLength: 1, maxLength: 120 }
        trigger: { $ref: "#/components/schemas/WorkflowTrigger" }
        graph: { $ref: "#/components/schemas/WorkflowGraph" }

    WorkflowNodeConfigField:
      type: object
      additionalProperties: false
      description: >
        ATH-191 — one config input the editor renders for a node type.
        `type` picks the control; `options` populate a select; the numeric
        bounds and `max_length` mirror the node's server-side validate().
      required: [name, label, type, required]
      properties:
        name: { type: string }
        label: { type: string }
        type: { type: string, enum: [text, textarea, number, boolean, select, string_list, key_value, json] }
        required: { type: boolean }
        placeholder: { type: [string, "null"] }
        help: { type: [string, "null"] }
        max_length: { type: [integer, "null"] }
        min: { type: [integer, "null"] }
        max: { type: [integer, "null"] }
        options:
          type: array
          description: Present for type=select.
          items:
            type: object
            required: [value, label]
            properties:
              value: { type: string }
              label: { type: string }
        depends_on:
          description: When set, the field shows only if config[field]==value.
          oneOf:
            - type: object
              required: [field, value]
              properties:
                field: { type: string }
                value: {}
            - type: "null"

    WorkflowNodeType:
      type: object
      additionalProperties: false
      description: >
        ATH-191 — a palette entry: a registered node type plus the editor
        metadata to render it. `branches` are the outgoing edge labels a
        node emits (condition → ["true","false"]); `terminal` nodes end a
        run; exactly one `singleton` trigger is allowed per graph.
      required: [type, label, category, terminal, singleton, branches, config_fields]
      properties:
        type: { type: string }
        label: { type: string }
        description: { type: [string, "null"] }
        category: { type: string, enum: [trigger, messaging, logic, routing, data, integration, terminal] }
        terminal: { type: boolean }
        singleton: { type: boolean }
        branches:
          type: array
          items: { type: string }
        config_fields:
          type: array
          items: { $ref: "#/components/schemas/WorkflowNodeConfigField" }

    WorkflowSimulationInput:
      type: object
      description: >
        ATH-193 — the hypothetical the draft graph is walked against.
        Everything is optional: an empty body simulates the trigger firing
        with no payload at all.
      properties:
        context:
          type: object
          description: >
            The trigger payload, seeded into the run context exactly as a
            real trigger would seed it. Condition nodes read it by dot-path
            (a node keyed `trigger.plan` reads `context.trigger.plan`).
          additionalProperties: true
        answers:
          type: object
          description: >
            Scripted replies for question nodes, keyed by the node's
            `save_as`. The value lands at context `answers.<save_as>` just
            as a real visitor reply would, so downstream conditions branch
            on it. A question with no scripted answer still advances; its
            answer is simply absent from context.
          additionalProperties: { type: string }
        outcomes:
          type: object
          description: >
            Scripted results for nodes whose real outcome cannot be known
            without doing the thing, keyed by node id. For `ai_answer` and
            `action` the value is the branch to take (`answered`,
            `escalated`, `ok`, `denied`, …); an unscripted node takes its
            documented default (`answered` / `ok`).
          additionalProperties: { type: string }

    WorkflowSimulationStep:
      type: object
      additionalProperties: false
      description: >
        ATH-193 — one node visit. `summary` is the human sentence the canvas
        shows ("would send: Hi there"); `detail` is the node's structured
        step output, the same shape a real run records. `context` is the run
        context AFTER this step, so an author can watch values accumulate.
      required: [index, node_id, node_type, result, summary, detail, context, branch, wait, error]
      properties:
        index: { type: integer, description: Zero-based position in the trace }
        node_id: { type: string }
        node_type: { type: string }
        result:
          type: string
          enum: [advanced, waited, completed, failed]
          description: What the node told the interpreter to do, verbatim from the runtime.
        branch:
          type: [string, "null"]
          description: The labeled edge taken, when the node branched.
        summary: { type: string }
        detail:
          type: object
          additionalProperties: true
        wait:
          description: >
            Present only when the node WOULD have parked. A real run stops
            here; the simulation reports the wait and carries on.
          oneOf:
            - $ref: "#/components/schemas/WorkflowSimulationWait"
            - type: "null"
        context:
          type: object
          additionalProperties: true
        error: { type: [string, "null"] }

    WorkflowSimulationWait:
      type: object
      description: ATH-193 — the park a real run would have made at this node.
      required: [kind, summary]
      properties:
        kind:
          type: string
          enum: [delay, event]
          description: >
            `delay` is a timer park (the run sleeps); `event` is an
            event park (the run waits for something to happen, e.g. a
            visitor reply or a human approval).
        summary: { type: string, description: "e.g. \"would wait 3 days\"" }
        seconds: { type: [integer, "null"], description: Timer parks only. }
        event: { type: [string, "null"], description: Event parks only — the domain event awaited. }
        scripted_answer:
          type: [string, "null"]
          description: The scripted reply folded into context to continue past an event park.

    WorkflowSimulation:
      type: object
      additionalProperties: false
      description: >
        ATH-193 — the result of walking the draft. `status` is how the run
        WOULD have ended had every park been instant: `completed`, `failed`
        (a node failed or the loop guard tripped), or `running` if the trace
        was cut short. Nothing here was persisted.
      required: [workflow_id, status, step_count, steps, context, error]
      properties:
        workflow_id: { $ref: "#/components/schemas/Ulid" }
        status:
          type: string
          enum: [completed, failed, running]
        step_count: { type: integer }
        error: { type: [string, "null"] }
        steps:
          type: array
          items: { $ref: "#/components/schemas/WorkflowSimulationStep" }
        context:
          type: object
          description: The run context at the end of the trace.
          additionalProperties: true

    CustomDomain:
      type: object
      additionalProperties: false
      description: >
        ATH-093 — a tenant-attached help-center hostname (Doc 03 §5).
        `cname_target` is what the tenant's DNS must point at.


        PA-299 — TWO FACTS, NOT ONE. The hostname resolving to us and the
        edge holding a certificate for it are days apart and can differ
        forever, and the tenant cares about the second: it is what decides
        whether a browser can open their help centre. ATH-093 had a single
        `verified` that meant only the first, so onboarding completed, this
        field said `verified`, and the browser failed the TLS handshake —
        at the edge, before the application, so nothing logged it.


        `verified` now means SERVING: the certificate is live. The wait in
        between is `provisioning`, and it is the honest place for a tenant
        who has done everything right and is waiting on issuance.
      required: [id, hostname, status, cname_target]
      properties:
        id: { $ref: "#/components/schemas/Ulid" }
        hostname: { type: string }
        status:
          type: string
          enum: [pending, provisioning, verified, failed]
          description: >
            `pending` — added, DNS not yet checked. `provisioning` — DNS is
            correct and a certificate has been requested; nothing for the
            tenant to do but wait. `verified` — serving over TLS. `failed` —
            see `failure_reason`, which distinguishes a DNS problem the tenant
            must fix from an issuance problem they may not be able to.
        verified_at: { type: [string, "null"], format: date-time }
        last_checked_at: { type: [string, "null"], format: date-time }
        failure_reason: { type: [string, "null"] }
        cname_target: { type: string }
        certificate_status:
          type: [string, "null"]
          description: >
            The certificate as the hosting platform reports it — normally
            `pending` then `active`. `unmanaged` means this deployment issues
            no certificates at all (local development, or self-hosting that
            terminates TLS elsewhere); it is never `unmanaged` in production,
            which `content.custom_domain_platform` enforces.
        certificate_issued_at:
          type: [string, "null"]
          format: date-time
          description: When the certificate went live — null while provisioning.

    CustomDomainInput:
      type: object
      required: [hostname]
      properties:
        hostname:
          type: string
          description: Fully-qualified hostname, e.g. help.customer.com. Lowercased on save.

    ToolSummary:
      type: object
      description: >
        ATH-202 — a registered agent tool, list shape (Doc 05 §5). Never
        carries secrets: `credential_key` names the vault entry, the
        payload behind it is unreachable through the API by design.
      required:
        [id, workspace_id, name, slug, kind, enabled, description, credential_key, endpoint,
         updated_at]
      properties:
        id: { $ref: "#/components/schemas/Ulid" }
        workspace_id: { $ref: "#/components/schemas/Ulid" }
        name: { type: string }
        slug: { type: string }
        kind: { type: string, enum: [builtin, http, mcp] }
        enabled: { type: boolean }
        description: { type: [string, "null"] }
        credential_key:
          type: [string, "null"]
          description: Vault key of the linked credential, or null when none is linked.
        endpoint:
          type: [string, "null"]
          description: For http tools, the method and path this tool calls. Null for other kinds.
        updated_at: { type: [string, "null"], format: date-time }

    Tool:
      allOf:
        - $ref: "#/components/schemas/ToolSummary"
        - type: object
          description: Full tool record, including the kind-specific configuration.
          properties:
            config:
              type: object
              additionalProperties: true
              description: >
                Kind-specific configuration. For `http` this is the
                HttpToolConfig shape (description, parameters, request,
                response). Never contains a secret.
            parameters:
              type: object
              additionalProperties: true
              description: The JSON Schema an agent sees for this tool's arguments.
            created_at: { type: [string, "null"], format: date-time }

    HttpToolRequestConfig:
      type: object
      additionalProperties: false
      description: >
        ATH-202 — the outbound call an `http` tool makes. Templates use
        OpenAPI syntax — a `{name}` placeholder names one of the tool's
        DECLARED parameters and nothing else. There is deliberately no
        way to template a credential: auth is injected from the linked
        vault entry according to `auth.type`.
      required: [base_url, method]
      properties:
        base_url:
          type: string
          format: uri
          description: >
            Absolute http(s) origin. Checked against the SSRF guard at
            save time and again, with DNS resolution, at call time.
        method: { type: string, enum: [GET, HEAD, POST, PUT, PATCH, DELETE] }
        path:
          type: string
          description: Path appended to base_url. May contain {parameter} templates; substituted values are percent-encoded.
        query:
          type: object
          additionalProperties: { type: string }
        headers:
          type: object
          additionalProperties: { type: string }
          description: >
            Static or templated request headers. Authorization,
            Proxy-Authorization and Cookie are rejected — credentials
            belong in the vault.
        body:
          type: object
          additionalProperties: true
          description: JSON body template, used only by write verbs.
        auth:
          type: object
          properties:
            type: { type: string, enum: [none, bearer, basic, api_key_header] }
            header:
              type: string
              description: Header name for api_key_header. Defaults to X-Api-Key.
        allow_writes:
          type: boolean
          description: >
            Per-tool opt-in for POST/PUT/PATCH/DELETE. Off by default —
            without it only GET and HEAD are accepted.
        timeout_seconds: { type: integer, minimum: 1, maximum: 30 }
        max_response_bytes: { type: integer, minimum: 1024, maximum: 1048576 }

    HttpToolResponseConfig:
      type: object
      additionalProperties: false
      description: >
        ATH-202 — how the raw response is trimmed before an agent sees
        it. A projection, not a redactor: a nested object kept by name
        comes through whole.
      properties:
        root:
          type: [string, "null"]
          description: Dot path selecting the interesting part of the payload, e.g. data.orders.
        fields:
          type: array
          items: { type: string }
          description: Whitelist applied to the selection, or to every item when it is a list. Empty means pass through.
        max_items: { type: integer, minimum: 1, maximum: 100 }

    HttpToolConfig:
      type: object
      description: ATH-202 — the stored `config` of a `kind=http` tool.
      required: [request]
      properties:
        description:
          type: string
          description: What the agent reads to decide whether to call this tool.
        parameters:
          type: object
          additionalProperties: true
          description: JSON Schema object declaring the tool's arguments. Only these names may appear in templates.
        request: { $ref: "#/components/schemas/HttpToolRequestConfig" }
        response: { $ref: "#/components/schemas/HttpToolResponseConfig" }

    ToolInput:
      type: object
      required: [name, kind]
      properties:
        name: { type: string, minLength: 1, maxLength: 120 }
        slug:
          type: string
          description: Unique per workspace. Derived from the name when omitted.
        kind: { type: string, enum: [builtin, http, mcp] }
        config:
          type: object
          additionalProperties: true
          description: For kind=http this must satisfy HttpToolConfig.
        credential_key:
          type: [string, "null"]
          description: Vault key, in THIS workspace, whose payload is injected as auth.

    ToolUpdate:
      type: object
      description: Every field optional; only what is supplied changes.
      properties:
        name: { type: string, minLength: 1, maxLength: 120 }
        config:
          type: object
          additionalProperties: true
        credential_key: { type: [string, "null"] }
        enabled:
          type: boolean
          description: >
            Turning a tool on is what exposes it to the agent runtime.
            Refused with 422 when the tool's config does not validate.

    ToolImportRequest:
      type: object
      description: Supply exactly one of url or document.
      properties:
        url:
          type: string
          format: uri
          description: Location of the OpenAPI document. Fetched through the SSRF guard, size-capped, redirects re-checked.
        document:
          type: string
          description: A pasted OpenAPI 3.0/3.1 document, JSON or YAML.
        base_url:
          type: string
          description: >
            Overrides the spec's servers[0].url. Required when the spec
            has none, has a relative one, or uses {variables}.

    ToolImportOperation:
      type: object
      additionalProperties: false
      required: [operation_id, method, path, config]
      properties:
        operation_id: { type: string }
        method: { type: string }
        path: { type: string }
        summary: { type: [string, "null"] }
        config:
          allOf:
            - $ref: "#/components/schemas/HttpToolConfig"
          description: Ready to POST as a tool's config. Write verbs arrive with allow_writes false.

    ToolImportRefusal:
      type: object
      description: One operation the importer would not generate, and the reason.
      required: [operation, reason]
      properties:
        operation: { type: string }
        reason: { type: string }

    ToolImportPreview:
      type: object
      additionalProperties: false
      required: [operations, refusals]
      properties:
        base_url: { type: [string, "null"] }
        suggested_auth:
          type: [string, "null"]
          description: Auth type inferred from the spec's security schemes. A suggestion only — the secret still goes in the vault by hand.
        operations:
          type: array
          items: { $ref: "#/components/schemas/ToolImportOperation" }
        refusals:
          type: array
          items: { $ref: "#/components/schemas/ToolImportRefusal" }

    ToolInvocationRequest:
      type: object
      properties:
        arguments:
          type: object
          additionalProperties: true
          description: The arguments an agent would pass, matching the tool's declared parameter schema.

    ToolInvocationResult:
      type: object
      additionalProperties: false
      description: >
        ATH-202 — ToolResult on the wire (ATH-200's envelope). A tool
        that failed still returns HTTP 200: the CALL succeeded, the tool
        did not, and the author needs to see which.


        ATH-204 added `pending_approval`: a permission rule allowed the
        call but demanded a human first, so an approval task was opened
        and THE TOOL DID NOT RUN. Callers must treat anything other than
        `ok` as "did not produce output".
      required: [status, duration_ms, output, error, approval_request_id]
      properties:
        status: { type: string, enum: [ok, error, denied, pending_approval] }
        output:
          type: object
          additionalProperties: true
        error: { type: [string, "null"] }
        duration_ms: { type: integer }
        approval_request_id:
          type: [string, "null"]
          description: >
            ATH-204 — the approval task opened for this call. Set only
            when status is `pending_approval`.

    McpServer:
      type: object
      description: >
        ATH-203 — an external MCP server a workspace has attached (Doc
        05 §5). A CONNECTION, not a capability: on its own it can do
        nothing, and the capabilities are the `kind=mcp` tools discovery
        materialises from it, each disabled until a human enables it.
        Never carries a secret — `credential_key` names the vault entry
        and the payload behind it is unreachable through the API.
      required:
        [id, workspace_id, name, slug, url, transport, auth_type, enabled, auth_header,
         credential_key, timeout_seconds, max_response_bytes, protocol_version, server_info,
         last_discovered_at, last_discovery_error, discovered_tool_count, created_at, updated_at]
      properties:
        id: { $ref: "#/components/schemas/Ulid" }
        workspace_id: { $ref: "#/components/schemas/Ulid" }
        name: { type: string }
        slug:
          type: string
          description: Immutable. Prefixes the slug of every tool discovered from this server.
        url:
          type: string
          format: uri
          description: The MCP Streamable HTTP endpoint. Checked against the SSRF guard at save time and again, with DNS resolution, on every call.
        transport:
          type: string
          enum: [http]
          description: >
            Remote HTTP only. stdio is not supported and will not be:
            it would mean this platform executing a local process on
            behalf of a tenant, which is a different isolation problem.
        auth_type: { type: string, enum: [none, bearer, api_key_header] }
        auth_header:
          type: [string, "null"]
          description: Header name for api_key_header. Defaults to X-Api-Key.
        credential_key:
          type: [string, "null"]
          description: Vault key of the linked credential, or null when the server needs no auth.
        timeout_seconds: { type: integer, minimum: 1, maximum: 60 }
        max_response_bytes: { type: integer, minimum: 4096, maximum: 1048576 }
        enabled:
          type: boolean
          description: >
            Kill switch over the whole connection. False stops every
            tool on this server executing without deleting anything.
        protocol_version:
          type: [string, "null"]
          description: Protocol revision the server negotiated at the last handshake.
        server_info:
          type: [object, "null"]
          additionalProperties: true
          description: The serverInfo the server reported. Informational; nothing authorizes off it.
        last_discovered_at: { type: [string, "null"], format: date-time }
        last_discovery_error:
          type: [string, "null"]
          description: >
            Why the last discovery failed, or null. Remote-authored text,
            sanitized and length-bounded, and shown to an ADMIN only —
            it is never forwarded to a model.
        discovered_tool_count: { type: integer }
        created_at: { type: [string, "null"], format: date-time }
        updated_at: { type: [string, "null"], format: date-time }

    McpServerInput:
      type: object
      required: [name, url]
      properties:
        name: { type: string, minLength: 1, maxLength: 120 }
        slug:
          type: string
          maxLength: 60
          description: Lowercase letters, digits and hyphens. Derived from the name when omitted.
        url: { type: string, format: uri, maxLength: 2048 }
        transport: { type: string, enum: [http] }
        auth_type: { type: string, enum: [none, bearer, api_key_header] }
        auth_header: { type: [string, "null"], maxLength: 120 }
        credential_key:
          type: [string, "null"]
          description: Vault key, in THIS workspace, whose payload is injected as auth.
        timeout_seconds: { type: integer, minimum: 1, maximum: 60 }
        max_response_bytes: { type: integer, minimum: 4096, maximum: 1048576 }
        enabled: { type: boolean }

    McpServerUpdate:
      type: object
      description: Every field optional; only what is supplied changes. slug and transport are immutable.
      properties:
        name: { type: string, minLength: 1, maxLength: 120 }
        url: { type: string, format: uri, maxLength: 2048 }
        auth_type: { type: string, enum: [none, bearer, api_key_header] }
        auth_header: { type: [string, "null"], maxLength: 120 }
        credential_key: { type: [string, "null"] }
        timeout_seconds: { type: integer, minimum: 1, maximum: 60 }
        max_response_bytes: { type: integer, minimum: 4096, maximum: 1048576 }
        enabled: { type: boolean }

    McpDiscoveredTool:
      type: object
      additionalProperties: false
      description: ATH-203 — one tool as of this discovery run, and what the run did to it.
      required: [id, slug, name, remote_name, enabled, change, review_reason]
      properties:
        id: { $ref: "#/components/schemas/Ulid" }
        slug: { type: string }
        name: { type: string }
        remote_name:
          type: string
          description: The tool's name on the remote server — half of the identity re-discovery matches on.
        enabled:
          type: boolean
          description: Always false for a newly created tool. Discovery never sets this true.
        change:
          type: string
          enum: [created, updated, unchanged, disabled, missing]
          description: >
            `disabled` means the definition changed under a tool that was
            enabled and it has been switched off pending review.
            `missing` means the server no longer offers it.
        review_reason:
          type: [string, "null"]
          description: >
            Why discovery disabled the tool: `definition_changed` or
            `removed_upstream`. Null when the run did not disable it.

    McpDiscoveryReport:
      type: object
      additionalProperties: false
      description: ATH-203 — the result of one discover call.
      required: [created, updated, unchanged, disabled, skipped, tools, protocol_version, server]
      properties:
        created: { type: integer }
        updated: { type: integer }
        unchanged: { type: integer }
        disabled:
          type: integer
          description: Tools this run switched OFF — definition changed, or withdrawn upstream.
        skipped:
          type: integer
          description: >
            Entries the server offered that could not be materialised —
            an unusable name, a non-object argument schema, an
            oversized schema, or a slug already taken by an unrelated
            tool.
        protocol_version: { type: string }
        tools:
          type: array
          items: { $ref: "#/components/schemas/McpDiscoveredTool" }
        server: { $ref: "#/components/schemas/McpServer" }

    ToolPermissionRule:
      type: object
      description: >
        ATH-204 (Doc 05 §5) — one rule in a tool's permission policy.
        Rules are evaluated in priority order and the FIRST match wins;
        no match means the call is denied.
      required:
        [id, workspace_id, tool_id, caller_kind, effect, priority, constraints,
         approval_ttl_minutes, description, created_at, updated_at]
      properties:
        id: { $ref: "#/components/schemas/Ulid" }
        workspace_id: { $ref: "#/components/schemas/Ulid" }
        tool_id: { $ref: "#/components/schemas/Ulid" }
        caller_kind:
          type: string
          enum: [agent, workflow, human, any]
          description: >
            Which caller this rule governs. `agent` is the AI agent
            runtime, `workflow` a workflow action node, `human` an
            operator-initiated call such as the tool builder's try-it
            button, and `any` matches all three.
        effect:
          type: string
          enum: [allow, require_approval, deny]
        priority:
          type: integer
          description: Evaluation order, ascending. Ties break on id.
        constraints:
          type: array
          description: >
            Argument conditions that must ALL hold for this rule to
            match. An empty list matches any arguments.
          items: { $ref: "#/components/schemas/ToolArgumentConstraint" }
        approval_ttl_minutes:
          type: [integer, "null"]
          description: >
            How long an approval task opened by this rule stays
            actionable before it expires. Only meaningful when effect is
            `require_approval`.
        description: { type: [string, "null"] }
        created_at: { type: [string, "null"], format: date-time }
        updated_at: { type: [string, "null"], format: date-time }

    ToolArgumentConstraint:
      type: object
      description: >
        ATH-204 — one condition on a tool call's arguments, e.g. the
        Doc 05 §5 example "refund <= $50" as
        `{argument: amount, operator: lte, value: 5000}`. Fail-closed:
        if the argument is absent, or its type does not support the
        operator, the constraint does NOT hold and the rule does not
        match.
      required: [argument, operator, value]
      properties:
        argument:
          type: string
          description: Argument name. Dot-paths address nested arguments.
        operator:
          type: string
          enum: [eq, neq, lt, lte, gt, gte, in, not_in, present, absent]
        value:
          description: >
            Comparison value. A list for `in` and `not_in`; a number for
            the ordering operators; omitted for `present` and `absent`.

    ToolPermissionRuleInput:
      type: object
      required: [caller_kind, effect]
      properties:
        caller_kind: { type: string, enum: [agent, workflow, human, any] }
        effect: { type: string, enum: [allow, require_approval, deny] }
        priority: { type: integer, minimum: 0, maximum: 10000, default: 100 }
        constraints:
          type: array
          items: { $ref: "#/components/schemas/ToolArgumentConstraint" }
        approval_ttl_minutes: { type: [integer, "null"], minimum: 1, maximum: 43200 }
        description: { type: [string, "null"], maxLength: 500 }

    ToolPermissionRuleUpdate:
      type: object
      description: Every field optional; only supplied fields change.
      properties:
        caller_kind: { type: string, enum: [agent, workflow, human, any] }
        effect: { type: string, enum: [allow, require_approval, deny] }
        priority: { type: integer, minimum: 0, maximum: 10000 }
        constraints:
          type: array
          items: { $ref: "#/components/schemas/ToolArgumentConstraint" }
        approval_ttl_minutes: { type: [integer, "null"], minimum: 1, maximum: 43200 }
        description: { type: [string, "null"], maxLength: 500 }

    ToolApprovalSummary:
      type: object
      description: >
        ATH-204 — a tool call waiting on a human (Doc 05 §5 "approval
        task appears in inbox"). While the status is `pending` the tool
        has NOT run.
      required:
        [id, workspace_id, tool_id, tool_slug, caller_kind, status, arguments, expires_at,
         tool_name, reason, conversation_id, created_at]
      properties:
        id: { $ref: "#/components/schemas/Ulid" }
        workspace_id: { $ref: "#/components/schemas/Ulid" }
        tool_id: { $ref: "#/components/schemas/Ulid" }
        tool_slug: { type: string }
        tool_name: { type: [string, "null"] }
        caller_kind: { type: string, enum: [agent, workflow, human] }
        status: { type: string, enum: [pending, approved, denied, expired] }
        reason:
          type: [string, "null"]
          description: Why approval was required, from the matching rule.
        arguments:
          type: object
          additionalProperties: true
          description: >
            The arguments frozen at request time. These, and only these,
            are what execution will use.
        conversation_id:
          type: [string, "null"]
          description: The conversation this call arose from, when there was one.
        expires_at: { type: string, format: date-time }
        created_at: { type: [string, "null"], format: date-time }

    ToolApproval:
      allOf:
        - $ref: "#/components/schemas/ToolApprovalSummary"
        - type: object
          description: Full approval task including provenance and outcome.
          properties:
            tool_permission_rule_id: { type: [string, "null"] }
            origin_type:
              type: [string, "null"]
              description: >
                What initiated the call — `workflow_run`, `agent_run`,
                or `test_invoke`. The audit trail proper is ATH-205's
                agent_actions table.
            origin_id: { type: [string, "null"] }
            requested_by_user_id: { type: [string, "null"] }
            decided_by_user_id: { type: [string, "null"] }
            decided_at: { type: [string, "null"], format: date-time }
            decision_note: { type: [string, "null"] }
            executed_at: { type: [string, "null"], format: date-time }
            execution_status:
              type: [string, "null"]
              description: >
                The envelope status the tool returned once it ran — `ok`,
                `error`, or `denied`. Null until execution happens, and
                permanently null for a denied or expired task.
            result:
              type: [object, "null"]
              additionalProperties: true

    ToolApprovalDecision:
      type: object
      properties:
        note:
          type: [string, "null"]
          maxLength: 1000
          description: Optional free-text rationale recorded with the decision.

    AgentActionSummary:
      type: object
      description: >
        ATH-205 — one tool invocation on the record (Doc 05 §5). One row
        per attempt, from the moment the platform was asked to run a
        tool through to whatever came of it.
      required:
        [id, workspace_id, tool_slug, caller_kind, decision, status, duration_ms, occurred_at,
         tool_id, tool_name, caller_user_id, reason, conversation_id, approval_request_id,
         decided_by_user_id]
      properties:
        id: { $ref: "#/components/schemas/Ulid" }
        workspace_id: { $ref: "#/components/schemas/Ulid" }
        tool_id:
          type: [string, "null"]
          description: >
            Null once the tool has been deleted. `tool_slug` is
            denormalised onto the row precisely so the audit outlives
            the configuration it describes.
        tool_slug: { type: string }
        tool_name: { type: [string, "null"] }
        caller_kind:
          type: string
          enum: [agent, workflow, human]
          description: >
            Who asked — the AI agent runtime, a workflow action node, or
            an operator. `human` includes the tool builder's try-it
            button.
        caller_user_id:
          type: [string, "null"]
          description: The operator, when the caller was a human.
        decision:
          type: string
          enum: [allowed, denied, approval_required]
          description: What the permission engine said before anything ran.
        status:
          type: string
          enum: [ok, error, denied, pending_approval, expired]
          description: >
            How the invocation ended. `pending_approval` means the tool
            has NOT run and a human has been asked; the same row moves
            to `ok`/`error` if the call is approved and executes, to
            `denied` if a human refuses, and to `expired` if nobody
            answered in time.
        reason:
          type: [string, "null"]
          description: Why the call was denied or parked, from the matching rule.
        duration_ms: { type: integer }
        conversation_id: { type: [string, "null"] }
        approval_request_id:
          type: [string, "null"]
          description: The approval task this call was parked on, when it was parked.
        decided_by_user_id:
          type: [string, "null"]
          description: The human who approved or denied the call.
        occurred_at: { type: string, format: date-time }

    AgentAction:
      allOf:
        - $ref: "#/components/schemas/AgentActionSummary"
        - type: object
          description: >
            The full record, including the REDACTED arguments and
            result. See the endpoint description for the redaction
            contract.
          properties:
            tool_permission_rule_id:
              type: [string, "null"]
              description: >
                The rule that decided. Null when no rule matched — which
                is itself the denial, since the engine is fail-closed.
            origin_type:
              type: [string, "null"]
              description: What initiated the call — `workflow_run`, `agent_run`, or `test_invoke`.
            origin_id: { type: [string, "null"] }
            approval_outcome:
              type: [string, "null"]
              description: >
                How the approval task settled — `approved`, `denied`, or
                `expired`. Null when the call was never parked.
            decided_at: { type: [string, "null"], format: date-time }
            arguments:
              type: object
              additionalProperties: true
              description: The arguments the call was made with, redacted and truncated.
            result:
              type: [object, "null"]
              additionalProperties: true
              description: >
                The output the tool returned, redacted and truncated.
                Null when the tool never ran.
            error:
              type: [string, "null"]
              description: The failure message when the invocation ended in `error`.
            redacted_keys:
              type: array
              description: >
                Dot-paths whose values were masked before storage. An
                empty list means nothing was masked.
              items: { type: string }
            payload_scrubbed_at:
              type: [string, "null"]
              format: date-time
              description: >
                PA-268 — when this row's `arguments` and `result` were
                dropped. Non-null means the payloads are GONE and the
                empty/null values above say nothing about what the tool
                was asked or what it returned; every other field on the
                row is untouched and remains the record of the
                invocation. Null means the payloads are as stored.
                Reading an empty payload without checking this field is
                the one way to misread the audit.
            payload_scrub_reason:
              # No `enum` alongside the nullable type, matching
              # `approval_outcome` above: the values are named in the
              # description instead. A three-member enum with a null in
              # it crashes the spectral:oas ruleset outright, and a
              # ruleset that cannot run is not a gate (ATH-005).
              type: [string, "null"]
              description: >
                WHY the payloads are gone. `retention` — the payload
                retention window passed and the daily sweep dropped
                them. `workspace_opt_out` — the workspace has
                `settings.tools.store_action_payloads` set to false, so
                the payloads were never written in the first place.
                Null exactly when `payload_scrubbed_at` is null.
            created_at: { type: [string, "null"], format: date-time }

    AutomationRunStep:
      type: object
      description: >
        PA-220 — one executed step of a "do it for me" run (Doc 07 §2.2).
        Records WHICH selector resolved and THAT an action happened —
        NEVER the value typed into a field (CLAUDE.md rule 3). A `fill`
        step names the field via `selector_used` and nothing else.
      required: [id, step_index, action, outcome, sensitive_manual, duration_ms, occurred_at]
      properties:
        id: { $ref: "#/components/schemas/Ulid" }
        step_index:
          type: integer
          description: Zero-based position of this step within the run's execution order.
        guide_step_id:
          type: [string, "null"]
          description: >
            The guide step this executed, or null once that step has been
            deleted — the run's own record of what ran survives it.
        action:
          type: string
          enum: [click, fill, navigate, select, submit, scroll, keypress]
          description: What the runtime did. `fill` never carries the value entered.
        selector_used:
          type: [string, "null"]
          description: >
            WHICH selector candidate resolved the target element — the
            "where", never the "what". Null for actions without a target
            (e.g. `navigate`).
        outcome:
          type: string
          enum: [ok, failed, skipped_manual, aborted]
          description: >
            How the step ended. `skipped_manual` means it was handed to
            the user for manual entry (see `sensitive_manual`); `aborted`
            means the run was stopped before this step completed.
        sensitive_manual:
          type: boolean
          description: >
            True when the step was handed to the user for manual entry
            because the field was masked or matched a sensitive pattern.
            The platform never types into, nor records, such a field.
        error:
          type: [string, "null"]
          description: The failure message when the step's outcome is `failed`.
        duration_ms: { type: integer }
        screenshot_ref:
          type: [string, "null"]
          description: >
            A storage reference to an optional screenshot — never an
            inline image, and never carrying page content in the audit
            row itself.
        occurred_at: { type: string, format: date-time }

    AutomationRunSummary:
      type: object
      description: >
        PA-220 — one "do it for me" execution run (Doc 07 §2.2). One row
        per SESSION, from the moment the runtime was asked to execute a
        guide through to how the run ended.
      required:
        [id, workspace_id, guide_title, initiator_kind, host, status, started_at, guide_id,
         conversation_id, initiator_user_id, initiator_visitor_ref, abort_reason, ended_at]
      properties:
        id: { $ref: "#/components/schemas/Ulid" }
        workspace_id: { $ref: "#/components/schemas/Ulid" }
        guide_id:
          type: [string, "null"]
          description: >
            Null once the guide has been deleted. `guide_title` is
            snapshotted onto the row precisely so the audit outlives the
            guide it describes.
        guide_title: { type: string }
        conversation_id:
          type: [string, "null"]
          description: The agent conversation this run was launched from, when it was.
        initiator_kind:
          type: string
          enum: [visitor, member, agent]
          description: >
            Who launched the run — an end-user visitor on the customer's
            app, a workspace member, or the AI agent itself.
        initiator_user_id:
          type: [string, "null"]
          description: The member who launched the run, when the initiator was a member.
        initiator_visitor_ref:
          type: [string, "null"]
          description: >
            An OPAQUE reference to the visitor who launched the run, when
            the initiator was a visitor. Never PII — an internal handle
            only, carrying no email, name, or address.
        host:
          type: string
          description: The domain the run acted on — the audit "where".
        status:
          type: string
          enum: [running, completed, aborted, failed]
          description: >
            How the run ended. `running` is the live state; a run moves
            once to `completed`, `aborted` (a rail stopped it), or
            `failed`, and that terminal fact is never rewritten.
        abort_reason:
          type: [string, "null"]
          description: >
            Why an aborted or failed run stopped — one of `user_stopped`,
            `selector_ambiguous`, `sensitive_page`, `domain_not_allowed`,
            or `error`. Null while running or when it completed cleanly.
        started_at: { type: string, format: date-time }
        ended_at: { type: [string, "null"], format: date-time }

    AutomationRun:
      allOf:
        - $ref: "#/components/schemas/AutomationRunSummary"
        - type: object
          description: The full run, including its ordered executed steps.
          properties:
            steps:
              type: array
              description: The executed steps, in execution order (ascending `step_index`).
              items: { $ref: "#/components/schemas/AutomationRunStep" }
            created_at: { type: [string, "null"], format: date-time }

    AutomationRunHandle:
      type: object
      additionalProperties: false
      description: >
        PA-218 — the visitor-facing view of a "do it for me" run: just its
        id and state. Deliberately lean — the full audit (who/which
        guide/host/steps) is the member-only read surface (AutomationRun);
        the visitor runtime only needs the handle to record steps against
        and to see the terminal state it reached.
      required: [run_id, status, abort_reason]
      properties:
        run_id: { $ref: "#/components/schemas/Ulid" }
        status:
          type: string
          enum: [running, completed, aborted, failed]
        abort_reason:
          type: [string, "null"]
          description: >
            Why an aborted or failed run stopped — one of `user_stopped`,
            `selector_ambiguous`, `sensitive_page`, `domain_not_allowed`, or
            `error`. Null while running or on a clean completion.

    AutomationRunStepReceipt:
      type: object
      additionalProperties: false
      description: >
        PA-218 — the acknowledgement for one recorded step. RULE 3: it
        echoes WHICH field and THAT an action happened, never a typed
        value. A masked/sensitive field comes back `sensitive_manual: true`
        with `outcome: skipped_manual`.
      required: [id, step_index, action, outcome, sensitive_manual]
      properties:
        id: { $ref: "#/components/schemas/Ulid" }
        step_index: { type: integer }
        action:
          type: string
          enum: [click, fill, navigate, select, submit, scroll, keypress]
        outcome:
          type: string
          enum: [ok, failed, skipped_manual, aborted]
        sensitive_manual: { type: boolean }

    AutomationAllowedDomain:
      type: object
      additionalProperties: false
      description: >
        PA-219 — one product host the "do it for me" engine is allowed to
        act on (Doc 07 §2.2). Execution is authorised for a host ONLY when
        its row here is `verified`; a `pending` or `failed` row confers
        nothing. `verification_token` is the challenge the workspace
        serves at `well_known_path` on the host to prove control of it —
        it is a public challenge nonce, not a secret.
      required:
        [id, workspace_id, host, status, verification_method, verification_token, well_known_path,
         verified_at, last_checked_at, failure_reason, created_at]
      properties:
        id: { $ref: "#/components/schemas/Ulid" }
        workspace_id: { $ref: "#/components/schemas/Ulid" }
        host:
          type: string
          description: The normalised host, e.g. app.customer.com. Lowercased, no scheme/port/path.
        status:
          type: string
          enum: [pending, verified, failed]
          description: >
            `verified` is the only status that authorises execution.
            Moves pending → verified/failed via the async check.
        verification_method:
          type: string
          enum: [http_well_known]
          description: >
            How ownership is proven. `http_well_known` — serve the token
            at `well_known_path` over HTTPS. (DNS TXT is the reserved
            extension point; only the HTTP method ships in v1.)
        verification_token:
          type: string
          description: >
            The challenge the workspace must serve at `well_known_path` to
            prove control of the host. A public nonce, not a secret.
        well_known_path:
          type: string
          description: The path the token must be served at, e.g. /.well-known/cairn-automation-challenge.
        verified_at: { type: [string, "null"], format: date-time }
        last_checked_at: { type: [string, "null"], format: date-time }
        failure_reason:
          type: [string, "null"]
          description: Why the last check failed, when the status is `failed`.
        created_at: { type: [string, "null"], format: date-time }

    AutomationAllowedDomainInput:
      type: object
      required: [host]
      properties:
        host:
          type: string
          description: >
            The product host to allow, e.g. app.customer.com. A full URL
            is accepted and normalised down to its host (scheme, port and
            path are stripped); the result is lowercased on save.

    KnowledgePin:
      type: object
      additionalProperties: false
      description: >
        PA-228 — one manual pin (Doc 07 §4): a guide made relevant to the
        pages a URL pattern matches, optionally to one element via a
        selector. The MANUAL half of the page↔knowledge map; the AUTO
        half is derived from guides' per-step capture URLs and is not
        stored as a row.
      required:
        [id, workspace_id, guide_id, url_pattern, match_type, guide_title, guide_status, selector,
         created_by_user_id, created_at, updated_at]
      properties:
        id: { $ref: "#/components/schemas/Ulid" }
        workspace_id: { $ref: "#/components/schemas/Ulid" }
        guide_id: { $ref: "#/components/schemas/Ulid" }
        guide_title:
          type: [string, "null"]
          description: The pinned guide's title, denormalised for listing UIs.
        guide_status:
          type: [string, "null"]
          description: >
            The pinned guide's status (e.g. draft, published), so a UI can
            warn when a draft is pinned.
        url_pattern:
          type: string
          description: The URL or path pattern the guide is pinned to, e.g. /settings/billing.
        match_type:
          type: string
          enum: [exact, prefix, glob]
          description: >
            How `url_pattern` is compared to a page URL. `exact` — the
            tolerant walkthrough-player rule (origin + path equality up to
            a trailing slash, query subset, hash only when present).
            `prefix` — the path and everything under it. `glob` — a `*`
            wildcard path.
        selector:
          type: [string, "null"]
          description: >
            Optional CSS selector for element-level pinning (Scribe parity
            for pinned help). When set, the pin anchors the guide to one
            element on the matched page.
        created_by_user_id:
          type: [string, "null"]
          description: The member who created the pin, when known.
        created_at: { type: [string, "null"], format: date-time }
        updated_at: { type: [string, "null"], format: date-time }

    KnowledgePinInput:
      type: object
      required: [guide_id, url_pattern]
      properties:
        guide_id: { $ref: "#/components/schemas/Ulid" }
        url_pattern:
          type: string
          maxLength: 2048
          description: The URL or path pattern to pin the guide to, e.g. /settings/billing.
        match_type:
          type: string
          enum: [exact, prefix, glob]
          description: >
            How the pattern is matched against a page URL. Optional;
            defaults to `exact` when omitted.
        selector:
          type: [string, "null"]
          maxLength: 1024
          description: Optional CSS selector for element-level pinning.

    KnowledgePinUpdateInput:
      type: object
      description: >
        PA-228 — a partial update. The guide a pin points at is fixed
        (re-pin to move a guide); only where and how it surfaces changes.
      properties:
        url_pattern:
          type: string
          maxLength: 2048
        match_type:
          type: string
          enum: [exact, prefix, glob]
        selector:
          type: [string, "null"]
          maxLength: 1024
          description: Pass null to clear element-level pinning.

    WidgetPageMapEntry:
      type: object
      additionalProperties: false
      description: >
        PA-229 (Doc 07 §4) — one PUBLISHED guide relevant to the visitor's
        current page, the widget-facing projection of a PageKnowledgeMapEntry.
        Only the toast label (`title`) and the walkthrough target (`guide_id`)
        cross to the visitor; the matched step is resolved client-side from the
        guide the walkthrough player fetches.
      required: [guide_id, title]
      properties:
        guide_id: { $ref: "#/components/schemas/Ulid" }
        title: { type: string, description: The guide's title — the nudge toast's "Stuck on X?" label. }

    WidgetNudgeEventInput:
      type: object
      description: >
        PA-229 (Doc 07 §4) — one nudge-lifecycle report. INTERACTION METADATA
        ONLY (rule 3): the nudge/guide ids, the struggle `kind`, the page, and
        the `phase`. The server drops the URL's query + hash before storing.
        Idempotent on `(nudge_id, phase)` — a phase is reported at most once
        per nudge.
      required: [nudge_id, phase]
      properties:
        nudge_id:
          type: string
          maxLength: 64
          description: Client-generated id grouping one nudge's whole lifecycle.
        phase:
          type: string
          enum: [shown, dismissed, clicked, walkthrough_started, helped, conversation_started]
          description: >
            Where in the funnel this report sits. `shown`→`dismissed`/`clicked`
            →`walkthrough_started`→`helped` (walkthrough completed);
            `conversation_started` when the visitor opened a support
            conversation after the nudge (the non-deflection outcome).
        guide_id:
          oneOf: [{ $ref: "#/components/schemas/Ulid" }, { type: "null" }]
          description: The matched guide the nudge pointed at.
        kind:
          type: [string, "null"]
          maxLength: 32
          description: The struggle kind that triggered the nudge (rage_click, thrash, …).
        url:
          type: [string, "null"]
          maxLength: 2048
          description: The page the nudge fired on; stored as origin+path only.

    PageKnowledgeMapEntry:
      type: object
      additionalProperties: false
      description: >
        PA-228 — one guide relevant to a page URL (Doc 07 §4). `source`
        says why: `pinned` (a manual pin, carrying its `pin_id`, the
        `url_pattern`/`match_type` that matched, and any `selector`) or
        `auto` (a guide step's captured URL matched, carried in
        `matched_step_url`). Entries are ranked pinned-first and
        de-duplicated by guide.
      required: [guide_id, title, status, source, pin_id, url_pattern, match_type, selector, matched_step_url]
      properties:
        guide_id: { $ref: "#/components/schemas/Ulid" }
        title:
          type: string
          description: The guide's title.
        status:
          type: string
          description: The guide's status (e.g. draft, published).
        source:
          type: string
          enum: [pinned, auto]
          description: Why the guide is relevant — a manual pin or an automatic step-URL match.
        pin_id:
          type: [string, "null"]
          description: The pin's id, when `source` is `pinned`.
        url_pattern:
          type: [string, "null"]
          description: The pattern that matched, when `source` is `pinned`.
        match_type:
          type: [string, "null"]
          description: The pin's match type, when `source` is `pinned`.
        selector:
          type: [string, "null"]
          description: The element selector to anchor to, when the pin carries one.
        matched_step_url:
          type: [string, "null"]
          description: The guide step URL that matched, when `source` is `auto`.

    SidekickSearchRequest:
      type: object
      required: [query]
      description: >
        PA-230 — one internal-audience knowledge query from the employee
        Sidekick (Doc 07 §4).
      properties:
        query:
          type: string
          minLength: 2
          maxLength: 2000
          description: What the employee is asking the internal knowledge base.

    SidekickAnswer:
      type: object
      additionalProperties: false
      required: [id, query, answer, grounded, sources, credit_cost_cents, confidence]
      description: >
        PA-230 — a grounded answer over `internal`-audience knowledge for
        an employee (Doc 07 §4). Sibling of `CopilotSuggestion` but
        conversation-less: no mode, no draft, nothing delivered. Every
        cited source is `internal` by construction — retrieval never leaves
        that audience — so a teammate can trust the whole answer is drawn
        from material they are entitled to. When nothing internal grounds
        the query, `grounded` is false, `sources` is empty, and no credits
        are charged.
      properties:
        id: { $ref: "#/components/schemas/Ulid" }
        query:
          type: string
          description: The query this answer responds to, echoed back.
        answer:
          type: string
          description: The grounded answer, or a short decline when nothing internal covers the query.
        grounded:
          type: boolean
          description: >
            False when internal retrieval found nothing above the grounding
            floor. The Sidekick then declines rather than inventing, and no
            credits are spent.
        confidence:
          type: [number, "null"]
          format: float
          description: Similarity of the best retrieved chunk. Null when nothing was retrieved.
        sources:
          type: array
          description: >
            Citations backing the answer, resolved from retrieval rather
            than from model output. Always `internal`-audience. Empty when
            nothing grounded the query.
          items:
            type: object
            required: [kind, title, audience]
            properties:
              kind: { type: string, enum: [article, guide, link] }
              ref: { type: [string, "null"], description: "document ref, e.g. article:{id} or guide:{id}" }
              title: { type: string }
              url: { type: [string, "null"], format: uri }
              slug: { type: [string, "null"] }
              audience:
                type: string
                enum: [internal]
                description: >
                  Always `internal` — this surface retrieves no other
                  audience. Carried so the client can badge sources
                  consistently with the copilot.
        credit_cost_cents:
          type: integer
          description: What this answer debited. Zero when ungrounded or on an unlimited plan.

    WebhookEventType:
      type: object
      additionalProperties: false
      required: [name]
      properties:
        name:
          type: string
          description: >
            The event type to subscribe to. The enum is the DELIVERABLE
            CATALOG — every Cairn domain event that implements
            `PubliclyDeliverable` (ATH-251), and nothing else.


            ATH-253 published it here because the iPaaS clients in
            `tools/ipaas` need a machine-readable catalog to build their
            trigger lists from; before this it existed only as a runtime
            scan of PHP classes, so a TypeScript client had no way to
            know a trigger it offered could never fire. A Pest test
            asserts this list equals `WebhookEventCatalog::names()`
            exactly, in both directions, so it cannot drift from the
            events that actually exist.


            Adding a member is additive and compatible per the
            versioning promise above. REMOVING one is breaking — it
            silently stops a customer's automation — and ships as a new
            major version.
          enum:
            - article.feedback_submitted
            - contact.unsubscribed
            - content.published
            - content.retracted
            - content.verification_due
            - content.verification_expired
            - content.verified
            - content_push.connection_interrupted
            - conversation.created
            - conversation.message_received
            - conversation.quarantined
            - conversation.released
            - conversation.tagged
            - crm.connection_interrupted
            - ecommerce.connection_interrupted
            - guide.captured
            - guide.drift_cleared
            - guide.drifted
            - segment.contact_entered
            - segment.contact_left
            - segment_source.connection_interrupted
            - status.incident_opened
            - status.incident_updated
            - ticket.opened
            - ticket.sla_breached
        description:
          type: string

    WebhookEventEnvelope:
      type: object
      description: >
        ATH-251's delivered body, published as a schema by ATH-253 so
        that a trigger implementation can declare its output fields from
        the spec instead of from a copy of the docs (see
        `tools/ipaas`). Every field outside `data` is fixed for all
        event types, so a receiver routes on `type` and dedupes on `id`
        without knowing anything about the event.


        A Pest test renders a real payload through `WebhookPayload` and
        asserts the envelope keys equal this schema's properties, so
        this cannot describe a body Cairn does not send.
      required: [id, type, api_version, created_at, workspace_id, data]
      properties:
        id:
          allOf: [{ $ref: "#/components/schemas/Ulid" }]
          description: >
            The DELIVERY id, not the event id, and the same value as the
            `Idempotency-Key` header. Stable across every attempt of one
            delivery and across a redelivery of it, so a receiver that
            stores it collapses our retries — which at-least-once
            delivery obliges them to do.
        type: { $ref: "#/components/schemas/WebhookEventType/properties/name" }
        api_version:
          type: string
          description: >
            Dated payload-shape version, pinned per endpoint at
            creation and independent of the `/v1` URL version.
          example: "2026-07-19"
        created_at: { type: string, format: date-time }
        workspace_id: { $ref: "#/components/schemas/Ulid" }
        data:
          type: object
          description: >
            The event's own `webhookData()` allowlist — hand-authored per
            event type, deliberately NOT the analytics properties bag.
            Receivers MUST ignore keys they do not recognise; adding one
            is compatible and does not bump `api_version`.
          additionalProperties: true

    WebhookEndpoint:
      type: object
      required:
        [id, workspace_id, url, events, status, payload_version, description, disabled_at,
         disabled_reason, consecutive_failures, last_success_at, last_failure_at, created_at,
         updated_at]
      properties:
        id: { $ref: "#/components/schemas/Ulid" }
        workspace_id: { $ref: "#/components/schemas/Ulid" }
        url: { type: string }
        description: { type: [string, "null"] }
        events:
          type: array
          items: { type: string }
          description: Subscribed event type names
        status:
          type: string
          enum: [enabled, disabled]
        payload_version:
          type: string
          description: Dated payload-envelope version this endpoint receives
        disabled_at: { type: [string, "null"], format: date-time }
        disabled_reason:
          type: [string, "null"]
          description: Set when the endpoint was switched off automatically after sustained failure
        consecutive_failures:
          type: integer
          description: Consecutive deliveries that exhausted every attempt. Any success resets it to zero.
        last_success_at: { type: [string, "null"], format: date-time }
        last_failure_at: { type: [string, "null"], format: date-time }
        created_at: { type: [string, "null"], format: date-time }
        updated_at: { type: [string, "null"], format: date-time }

    WebhookEndpointCreated:
      allOf:
        - $ref: "#/components/schemas/WebhookEndpoint"
        - type: object
          required: [secret]
          properties:
            secret:
              type: string
              description: >
                The HMAC-SHA256 signing secret. RETURNED ONCE, here only.
                Stored encrypted at rest with no reveal endpoint; a lost
                secret is rotated, not recovered.

    PublicWebhookEndpoint:
      type: object
      description: >
        ATH-253 — a webhook endpoint as the PUBLIC API exposes it.
        Deliberately narrower than `WebhookEndpoint`, and separate from
        it for the reason ATH-250 gave for `PublicContact`: the portal's
        shape may change whenever the portal changes and this one may
        not, so sharing one schema would mean a portal tweak silently
        editing a published contract.


        Dropped from the portal shape: `workspace_id` (the token names
        the tenant — there is no workspace in these paths, so echoing
        one back would imply a caller could choose it),
        `consecutive_failures` and `last_failure_at` (ATH-251's
        auto-disable tuning, which is ours to change), and `updated_at`.
        `status` and `disabled_reason` are KEPT: an auto-disabled
        endpoint is a silently dead automation, and the integration
        needs to be able to say so.
      required:
        [id, url, events, status, payload_version, created_at, description, disabled_at,
         disabled_reason, last_success_at]
      properties:
        id: { $ref: "#/components/schemas/Ulid" }
        url: { type: string }
        description: { type: [string, "null"] }
        events:
          type: array
          items: { type: string }
          description: Subscribed event type names
        status:
          type: string
          enum: [enabled, disabled]
        payload_version:
          type: string
          description: >
            Dated payload-envelope version pinned at creation. A later
            envelope version does not change what this endpoint already
            receives.
        disabled_at: { type: [string, "null"], format: date-time }
        disabled_reason:
          type: [string, "null"]
          description: Why delivery was switched off after sustained failure. Null while healthy.
        last_success_at: { type: [string, "null"], format: date-time }
        created_at: { type: string, format: date-time }

    PublicWebhookEndpointCreated:
      allOf:
        - $ref: "#/components/schemas/PublicWebhookEndpoint"
        - type: object
          required: [secret]
          properties:
            secret:
              type: string
              description: >
                The HMAC-SHA256 signing secret for `Cairn-Signature`.
                RETURNED ONCE, here only. A REST-hook trigger should
                store it alongside the subscription and verify every
                delivery with it; see docs/webhooks.md for the recipe.

    PublicWebhookInput:
      type: object
      required: [url, events]
      properties:
        url:
          type: string
          maxLength: 2048
          description: >
            An http(s) URL that is not loopback, link-local, private,
            cloud-metadata, or an internal suffix. Validated without DNS
            at authoring time and with DNS on every delivery attempt.
        description:
          type: [string, "null"]
          maxLength: 200
          description: Free text. An iPaaS client should identify itself and the automation here.
        events:
          type: array
          minItems: 1
          maxItems: 50
          items: { type: string }
          description: Event type names from `/webhook-event-types`. An unknown name is a 422.

    WebhookEndpointInput:
      type: object
      required: [url, events]
      properties:
        url:
          type: string
          maxLength: 2048
          description: >
            An http(s) URL that is not loopback, link-local, private,
            cloud-metadata, or an internal suffix. Validated without DNS
            here and with DNS on every delivery.
        description: { type: [string, "null"], maxLength: 200 }
        events:
          type: array
          minItems: 1
          maxItems: 50
          items: { type: string }
          description: Event type names from the webhook-event-types catalog
        status:
          type: string
          enum: [enabled, disabled]
          default: enabled

    WebhookEndpointPatchInput:
      type: object
      description: "Every field optional; only what is supplied changes."
      properties:
        url: { type: string, maxLength: 2048 }
        description: { type: [string, "null"], maxLength: 200 }
        events:
          type: array
          minItems: 1
          maxItems: 50
          items: { type: string }
        status:
          type: string
          enum: [enabled, disabled]

    WebhookDelivery:
      type: object
      required:
        [id, workspace_id, endpoint_id, event_name, status, attempt_count, idempotency_key,
         payload_version, max_attempts, last_status_code, last_error, next_attempt_at,
         delivered_at, redelivery_of, created_at]
      properties:
        id: { $ref: "#/components/schemas/Ulid" }
        workspace_id: { $ref: "#/components/schemas/Ulid" }
        endpoint_id: { $ref: "#/components/schemas/Ulid" }
        idempotency_key:
          type: string
          description: >
            The stable identity of the delivered event. Sent as the
            `Idempotency-Key` header and as the envelope's `id`, constant
            across retries AND across a redelivery.
        event_name: { type: string }
        payload_version: { type: string }
        status:
          type: string
          enum: [pending, succeeded, failed]
        attempt_count: { type: integer }
        max_attempts: { type: integer }
        last_status_code: { type: [integer, "null"] }
        last_error: { type: [string, "null"] }
        next_attempt_at: { type: [string, "null"], format: date-time }
        delivered_at: { type: [string, "null"], format: date-time }
        redelivery_of:
          type: [string, "null"]
          description: The delivery this one replays, when it is a redelivery
        created_at: { type: [string, "null"], format: date-time }

    WebhookDeliveryDetail:
      allOf:
        - $ref: "#/components/schemas/WebhookDelivery"
        - type: object
          required: [body, attempts]
          properties:
            body:
              type: string
              description: >
                The verbatim signed bytes that were sent. A STRING, not
                an object, and deliberately so: these are the exact
                bytes the HMAC was computed over, and re-encoding them
                to present a parsed object would produce a body whose
                signature no longer verifies — which is the one thing a
                delivery log has to be able to prove.


                The string contains JSON of the shape below, declared
                with JSON Schema 2020-12's `contentSchema` (OpenAPI 3.1)
                so the relationship is machine-readable rather than
                asserted in prose.
              contentMediaType: application/json
              contentSchema: { $ref: "#/components/schemas/WebhookEventEnvelope" }
            attempts:
              type: array
              items: { $ref: "#/components/schemas/WebhookDeliveryAttempt" }

    WebhookDeliveryAttempt:
      type: object
      required: [id, attempt, ok, attempted_at]
      properties:
        id: { $ref: "#/components/schemas/Ulid" }
        attempt: { type: integer }
        ok: { type: boolean }
        status_code: { type: [integer, "null"] }
        duration_ms: { type: integer }
        error:
          type: [string, "null"]
          description: >
            Why the attempt failed, including an SSRF-guard refusal,
            which is recorded as a failed attempt and never as a success.
        attempted_at: { type: string, format: date-time }

    SlackInstallation:
      type: object
      additionalProperties: false
      required: [id, team_id, team_name, status, connected_at]
      properties:
        id: { $ref: "#/components/schemas/Ulid" }
        team_id:
          type: string
          description: Slack's workspace ("team") id — theirs, not ours.
        team_name: { type: string }
        bot_user_id: { type: string }
        notify_channel_id:
          type: [string, "null"]
          description: >
            Null means connected but silent — nothing is announced until
            a channel is chosen.
        notify_channel_name: { type: [string, "null"] }
        status:
          type: string
          enum: [connected, disabled, revoked]
          description: >
            `disabled` is the automatic state after sustained delivery
            failure, or immediately when Slack tells us the token is
            dead. It requires an explicit PATCH to leave.
        disabled_reason: { type: [string, "null"] }
        consecutive_failures: { type: integer }
        connected_at: { type: string, format: date-time }
        revoked_at: { type: [string, "null"], format: date-time }

    SlackInstallationPatchInput:
      type: object
      properties:
        notify_channel_id:
          type: [string, "null"]
          maxLength: 32
          description: Send null to go silent without disconnecting.
        notify_channel_name: { type: [string, "null"], maxLength: 255 }
        status:
          type: string
          enum: [connected]
          description: >
            The only accepted transition — re-enabling after an
            automatic disable. A workspace cannot disable itself this
            way; that is what clearing the channel is for.

    SlackAuthorizeUrl:
      type: object
      additionalProperties: false
      required: [authorize_url, expires_at]
      properties:
        authorize_url: { type: string, format: uri }
        expires_at:
          type: string
          format: date-time
          description: When the signed `state` in the URL stops being accepted.

    CrmProvider:
      type: object
      additionalProperties: false
      required: [key, label, supports_refresh, default_field_map]
      properties:
        key:
          type: string
          enum: [hubspot, pipedrive]
          description: >
            The two adapters ATH-255 shipped. Doc 06 §6 also names
            Salesforce and Zoho; they are follow-ups, and absent here
            rather than present and broken.
        label: { type: string }
        supports_refresh:
          type: boolean
          description: >
            Whether the credential can be renewed. False means a static
            API token (Pipedrive) — a token that stops working needs a
            human with a new one, not a retry.
        default_field_map:
          type: object
          additionalProperties: { type: string }
          description: Cairn field name => the vendor's field name.

    CrmConnection:
      type: object
      additionalProperties: false
      required:
        [id, provider, direction, conflict_strategy, field_map, status, connected_at,
         account_label, remote_account_id, sync_unsubscribed, disabled_reason,
         consecutive_failures, last_synced_at, revoked_at]
      properties:
        id: { $ref: "#/components/schemas/Ulid" }
        provider:
          type: string
          enum: [hubspot, pipedrive]
        account_label: { type: [string, "null"] }
        remote_account_id:
          type: [string, "null"]
          description: The CRM's own id for the connected account/portal.
        direction:
          type: string
          enum: [push, pull, bidirectional]
          description: >
            `push` (the default) sends Cairn's contacts to the CRM and
            never writes back. `bidirectional` is the only setting under
            which a conflict is reachable.
        conflict_strategy:
          type: string
          enum: [crm_wins, cairn_wins]
          description: >
            Consulted ONLY for a genuine three-way conflict — both sides
            moved the same field away from the last agreed value. Doc 06
            §6 specifies "last-write w/ log"; the log is kept and the
            timestamp comparison is not, because comparing clocks from
            two systems that do not share one discards a real edit
            whenever a bulk CRM import stamps a later time than the
            agent who actually typed the correction.
        field_map:
          type: object
          additionalProperties: { type: string }
          description: >
            THE PII ALLOWLIST. Only `email`, `name` and `phone` can
            appear. The free-form contact `attributes` blob, segments,
            conversation content and visitor session data are not
            syncable at all — not filtered downstream, but never
            assembled. Email flows Cairn → CRM only in every direction
            setting, because it is the join key.
        sync_unsubscribed:
          type: boolean
          description: >
            Default false. An unsubscribed contact asked one system to
            stop contacting them; copying them into a CRM that will is
            not a neutral act, so it takes an explicit opt-in.
        status:
          type: string
          enum: [connected, disabled, needs_reauth, revoked]
          description: >
            `disabled` is the automatic state after ten consecutive
            exhausted syncs. `needs_reauth` is separate on purpose: it
            means the CRM rejected the credential — most often a refresh
            token the customer's own admin revoked — and only
            re-authorising helps. Telling a workspace to wait would be
            wrong, so the two states are not collapsed.
        disabled_reason: { type: [string, "null"] }
        consecutive_failures: { type: integer }
        last_synced_at: { type: [string, "null"], format: date-time }
        connected_at: { type: string, format: date-time }
        revoked_at: { type: [string, "null"], format: date-time }

    CrmConnectInput:
      type: object
      required: [provider, access_token]
      properties:
        provider:
          type: string
          enum: [hubspot, pipedrive]
        access_token:
          type: string
          maxLength: 500
          description: >
            Written to the encrypted vault and to nowhere else. Never
            returned by any endpoint; there is no reveal.
        refresh_token:
          type: [string, "null"]
          maxLength: 500
          description: >
            OAuth providers only. Without one, an expiring access token
            has no path back to health and the connection will land in
            `needs_reauth` when it lapses.
        expires_in:
          type: [integer, "null"]
          minimum: 1
          description: Seconds until the access token expires.
        account_label: { type: [string, "null"], maxLength: 255 }
        remote_account_id: { type: [string, "null"], maxLength: 64 }
        direction:
          type: string
          enum: [push, pull, bidirectional]
        conflict_strategy:
          type: string
          enum: [crm_wins, cairn_wins]
        sync_unsubscribed: { type: boolean }
        field_map:
          type: object
          additionalProperties: { type: string }

    CrmConnectionPatchInput:
      type: object
      properties:
        direction:
          type: string
          enum: [push, pull, bidirectional]
        conflict_strategy:
          type: string
          enum: [crm_wins, cairn_wins]
        sync_unsubscribed: { type: boolean }
        field_map:
          type: object
          additionalProperties: { type: string }
        account_label: { type: [string, "null"], maxLength: 255 }
        status:
          type: string
          enum: [connected]
          description: >
            The only accepted transition — re-enabling after an
            automatic disable. A connection in `needs_reauth` is refused,
            because only re-authorising fixes a revoked grant.

    CrmSyncInput:
      type: object
      properties:
        contact_id:
          $ref: "#/components/schemas/Ulid"
          description: Omit to queue a backfill of every contact in the workspace.

    CrmSyncQueued:
      type: object
      additionalProperties: false
      required: [queued, sync_run_id]
      properties:
        queued:
          type: integer
          description: How many contacts were enqueued.
        sync_run_id:
          type: [string, "null"]
          description: >
            The run id, when a single contact was queued. Null for a
            backfill, which produces one run per contact.

    CrmSyncRun:
      type: object
      required: [id, connection_id, status, attempt_count, created_at]
      properties:
        id: { $ref: "#/components/schemas/Ulid" }
        connection_id: { $ref: "#/components/schemas/Ulid" }
        contact_id: { type: [string, "null"] }
        status:
          type: string
          enum: [pending, succeeded, failed]
        outcome:
          type: [string, "null"]
          # No bare `null` member: `type` already admits it, and a null
          # inside an enum array is what every other nullable enum in
          # this spec omits.
          enum: [created, updated, adopted, pulled, merged, noop]
          description: >
            `adopted` is the idempotency path being exercised: a remote
            record for this email already existed, so it was claimed
            instead of a second one being created. It is a distinct
            outcome rather than folded into `updated` so the path can be
            counted in production rather than inferred.
        remote_id: { type: [string, "null"] }
        attempt_count: { type: integer }
        max_attempts: { type: integer }
        conflicts:
          type: [array, "null"]
          description: >
            One entry per field where both sides moved away from the
            last agreed value. Carries BOTH values and the base, because
            a record that kept only the winner would document the
            decision and destroy the evidence for it.
          items:
            type: object
            required: [field, cairn, crm, winner]
            properties:
              field: { type: string }
              cairn: { type: [string, "null"] }
              crm: { type: [string, "null"] }
              base: { type: [string, "null"] }
              winner:
                type: string
                enum: [cairn, crm]
        provider_error:
          type: [string, "null"]
          description: >
            The vendor's own error code — e.g. `invalid_grant`,
            `rate_limited`. This is the failure mode an
            HTTP-status-only log cannot see.
        last_error: { type: [string, "null"] }
        next_attempt_at: { type: [string, "null"], format: date-time }
        completed_at: { type: [string, "null"], format: date-time }
        created_at: { type: string, format: date-time }

    SegmentSourceConnection:
      type: object
      additionalProperties: false
      required:
        [id, region, status, consecutive_failures, connected_at, label, disabled_reason,
         last_success_at, last_failure_at, revoked_at]
      properties:
        id: { $ref: "#/components/schemas/Ulid" }
        region:
          type: string
          enum: [us, eu]
          description: >
            The Segment Tracking API host a write key was minted for.
            `us` = api.segment.io, `eu` = events.eu1.segmentapis.com.
            Streaming EU-resident data to the US host is a residency
            violation, so the region is a first-class field rather than
            inferred.
        label: { type: [string, "null"] }
        status:
          type: string
          enum: [connected, disabled, revoked]
          description: >
            `disabled` is the automatic state after ten consecutive
            exhausted deliveries OR a rejected write key — both mean the
            stream has stopped and a human must re-connect with a valid
            key. `revoked` is a deliberate disconnect. There is no
            `needs_reauth`: a Segment source authenticates with a single
            write key, so "fix it" is always "re-key", never a token
            refresh.
        disabled_reason: { type: [string, "null"] }
        consecutive_failures: { type: integer }
        last_success_at: { type: [string, "null"], format: date-time }
        last_failure_at: { type: [string, "null"], format: date-time }
        connected_at: { type: string, format: date-time }
        revoked_at: { type: [string, "null"], format: date-time }

    SegmentSourceConnectInput:
      type: object
      required: [write_key]
      properties:
        write_key:
          type: string
          maxLength: 255
          description: >
            The Segment source write key. Written to the encrypted vault
            and to nowhere else; never returned by any endpoint, and there
            is no reveal. Sent to Segment as HTTP Basic auth
            (base64(write_key + ":")), never in a query string or a log.
        region:
          type: string
          enum: [us, eu]
          description: Defaults to `us`.
        label: { type: [string, "null"], maxLength: 255 }

    SegmentSourcePatchInput:
      type: object
      properties:
        region:
          type: string
          enum: [us, eu]
        label: { type: [string, "null"], maxLength: 255 }
        status:
          type: string
          enum: [connected]
          description: >
            The only accepted transition — re-enabling after an automatic
            disable, which clears the failure counter and reason. A source
            disabled for a rejected write key should be RE-CONNECTED with a
            new key instead; re-enabling alone would re-break it on the
            next delivery.

    SegmentSourceDelivery:
      type: object
      additionalProperties: false
      required: [id, connection_id, message_id, event_name, call_type, status, attempt_count, created_at]
      properties:
        id: { $ref: "#/components/schemas/Ulid" }
        connection_id: { $ref: "#/components/schemas/Ulid" }
        message_id:
          type: string
          description: >
            The Segment `messageId`, derived deterministically from
            (source, event name, event instant) and stable across every
            retry of this delivery. Segment dedupes on it, so a retried
            delivery is collapsed rather than double-counted in the
            warehouse.
        event_name:
          type: string
          description: The domain event name, e.g. `conversation.created`.
        call_type:
          type: string
          enum: [track, identify]
          description: >
            `track` records an action (the default for almost every
            event); `identify` sets a person's traits (a
            contact-identity change such as `contact.unsubscribed`). The
            properties/traits are the event's existing webhook allowlist —
            never a payload invented for Segment.
        status:
          type: string
          enum: [pending, succeeded, failed]
        attempt_count: { type: integer }
        max_attempts: { type: integer }
        last_status_code: { type: [integer, "null"] }
        last_error: { type: [string, "null"] }
        next_attempt_at: { type: [string, "null"], format: date-time }
        delivered_at: { type: [string, "null"], format: date-time }
        created_at: { type: string, format: date-time }

    ContentPushTarget:
      type: object
      additionalProperties: false
      required: [key, label]
      properties:
        key:
          type: string
          enum: [confluence, zendesk]
        label: { type: string }

    ContentPushConnection:
      type: object
      additionalProperties: false
      required:
        [id, target, base_url, target_settings, auto_push, auto_retract, status, connected_at,
         account_label, remote_container, disabled_reason, consecutive_failures, last_pushed_at,
         revoked_at]
      properties:
        id: { $ref: "#/components/schemas/Ulid" }
        target:
          type: string
          enum: [confluence, zendesk]
        account_label: { type: [string, "null"] }
        base_url:
          type: string
          description: >
            The customer's own Confluence wiki base or Zendesk subdomain.
            Every push to it passes the outbound SSRF guard, because it is
            workspace-supplied.
        remote_container:
          type: [string, "null"]
          description: >
            Where a new page is created — the Confluence space key, or the
            Zendesk section id.
        target_settings:
          $ref: "#/components/schemas/ContentPushTargetSettings"
        auto_push:
          type: boolean
          description: >
            Default false. When true, a `content.published` for a PUBLIC
            article auto-queues a push. Non-public articles never auto-push
            — a manual push can still send them, which is a deliberate
            human choice rather than an automation replicating members-only
            content into a system Cairn cannot govern.
        auto_retract:
          type: boolean
          description: >
            PA-269. Default false. When true, a `content.retracted` for an
            article this connection has already pushed queues an UNPUBLISH
            of the remote copy — Zendesk's translation `draft` flag,
            Confluence's archive. Never a delete: the page lives in the
            customer's own system under their governance, so the gentlest
            operation that removes public visibility is the only one
            offered, and a target with no such operation reports
            `unsupported` rather than falling back to destruction.


            Off means exactly today's behaviour: retracting in Cairn
            leaves the remote copy alone. The reason it is offered at all
            is that a retraction is frequently a takedown — wrong pricing,
            a leaked procedure, a compliance pull — and a retraction that
            silently leaves Cairn's own copies serving is worse than one
            that never happened.
        status:
          type: string
          enum: [connected, disabled, needs_reauth, revoked]
          description: >
            `disabled` is the automatic state after ten consecutive
            exhausted pushes. `needs_reauth` is separate on purpose: the
            target rejected the credential (401/403), and only reconnecting
            helps — telling a workspace to wait would be wrong.
        disabled_reason: { type: [string, "null"] }
        consecutive_failures: { type: integer }
        last_pushed_at: { type: [string, "null"], format: date-time }
        connected_at: { type: string, format: date-time }
        revoked_at: { type: [string, "null"], format: date-time }

    ContentPushTargetSettings:
      type: object
      description: >
        PA-324 — deliberately has NO `required` list. PA-318 gave it
        `[permission_group_id]`, which contradicted this schema's own
        description twice over: omission is a documented, meaningful state
        ("omitted or null = not sent"), and both keys are Zendesk-only, so a
        Confluence connection's bag is legitimately empty. An empty bag is
        the common case, not a defect.



        PA-273 — the settings that only ONE target understands, kept off
        the shared columns that every target has to share a meaning for.


        `remote_container` is the cautionary precedent: one column whose
        meaning is "the Confluence space key, OR the Zendesk section id",
        which every reader has to disambiguate against the row's `target`.
        A second and third such column (permission groups and user
        segments exist in Zendesk and have no Confluence counterpart)
        would be permanently null for half the table. This bag is where
        per-target concepts go instead, and it is where ATH-242's
        per-locale mapping will go when it lands — a locale→translation
        map is not a scalar column under any design.


        The bag is CLOSED (`additionalProperties: false`) and validated
        AGAINST THE ROW'S TARGET: a key this connection's target does not
        understand is a 422, never a stored value that is enforced
        nowhere. Confluence understands no keys today, so a
        `target_settings` PATCH against a Confluence connection is
        refused rather than quietly kept. PA-271 argued the closed-bag
        case for `AgentSettings`; it holds here for the stronger reason
        that these keys are access controls.


        WRITES ARE A DEEP MERGE, NOT A REPLACE (PA-270/PA-271's
        `SettingsMerge`): PATCHing `permission_group_id` alone leaves
        `user_segment_id` exactly as it was. Send an explicit `null` to
        CLEAR a key — which means "stop sending this field to Zendesk",
        NOT "send null to Zendesk". The distinction is load-bearing: see
        `user_segment_id`.
      additionalProperties: false
      properties:
        permission_group_id:
          type: [integer, "null"]
          minimum: 1
          description: >
            Zendesk only. The Guide permission group that governs who can
            EDIT and publish the pushed article. A Guide configured to
            require one refuses every create with a 422 until this is
            set, which is the block this field exists to lift.


            Omitted or null = not sent, so the Guide applies its own
            default. Cairn never substitutes an id the workspace did not
            choose.
        user_segment_id:
          type: [integer, "null"]
          minimum: 1
          description: >
            Zendesk only. The user segment that governs who can VIEW the
            pushed article, so a wrong value here is a disclosure rather
            than a cosmetic defect.


            Omitted or null = THE FIELD IS NOT SENT, and the Guide applies
            its own default. It is deliberately NOT sent as an explicit
            JSON null, because Zendesk reads `user_segment_id: null` as
            "visible to everyone" — the most permissive value there is.
            Clearing this setting must never be a way to publish an
            internal article to the world, so a cleared key becomes an
            absent field.

    ContentPushConnectInput:
      type: object
      required: [target, base_url, email, api_token]
      properties:
        target:
          type: string
          enum: [confluence, zendesk]
        base_url:
          type: string
          maxLength: 500
          description: >
            The customer's Confluence wiki base
            (`https://acme.atlassian.net/wiki`) or Zendesk base
            (`https://acme.zendesk.com`). Refused at connect time if it
            names an internal, single-label or credential-bearing host.
        email:
          type: string
          maxLength: 255
          description: >
            The account email of the API token. Written to the encrypted
            vault with the token and to nowhere else; never returned.
        api_token:
          type: string
          maxLength: 500
          description: >
            The Confluence/Zendesk API token. Written to the encrypted
            vault and to nowhere else. Never returned by any endpoint;
            there is no reveal.
        remote_container:
          type: [string, "null"]
          maxLength: 128
          description: The Confluence space key or Zendesk section id to create pages in.
        account_label: { type: [string, "null"], maxLength: 255 }
        auto_push: { type: boolean }
        auto_retract: { type: boolean }

    ContentPushConnectionPatchInput:
      type: object
      properties:
        auto_push: { type: boolean }
        auto_retract: { type: boolean }
        account_label: { type: [string, "null"], maxLength: 255 }
        remote_container: { type: [string, "null"], maxLength: 128 }
        target_settings:
          $ref: "#/components/schemas/ContentPushTargetSettings"
        status:
          type: string
          enum: [connected]
          description: >
            The only accepted transition — re-enabling after an automatic
            disable. A connection in `needs_reauth` is refused, because
            only reconnecting fixes a rejected credential.

    ContentPushInput:
      type: object
      properties:
        article_id:
          $ref: "#/components/schemas/Ulid"
          description: Omit to backfill every published article in the workspace.

    ContentPushQueued:
      type: object
      additionalProperties: false
      required: [queued]
      properties:
        queued:
          type: integer
          description: How many articles were enqueued.
        push_run_id:
          type: [string, "null"]
          description: >
            The run id, when a single article was queued. Null for a
            backfill, which produces one run per article.

    ContentPushRun:
      type: object
      additionalProperties: false
      required: [id, connection_id, operation, status, attempt_count, created_at]
      properties:
        id: { $ref: "#/components/schemas/Ulid" }
        connection_id: { $ref: "#/components/schemas/Ulid" }
        article_id:
          type: [string, "null"]
          description: >
            The article this run acted on, WHILE IT STILL EXISTS. The
            column is a foreign key that nulls when the article is
            deleted, so a run that outlives its article reads null here
            and names the article in `article_ref` instead.
        article_ref:
          type: [string, "null"]
          description: >
            PA-272. The article id as a durable value rather than a
            foreign key, so a run for a DELETED article still says which
            article it was for. Equal to `article_id` for every run whose
            article is still present; null only for runs recorded before
            PA-272 whose article had already been deleted. Read this, not
            `article_id`, when correlating a retraction of deleted
            content.
        operation:
          type: string
          enum: [push, retract]
          default: push
          description: >
            PA-269. `push` writes the article to the target; `retract`
            unpublishes the copy a previous push created, and runs only
            for a connection with `auto_retract` on. Every run recorded
            before PA-269 is a `push`.


            PA-272. A `retract` run is produced by an article being
            DELETED as well as by it being unpublished — the two are the
            same promise to a workspace that opted in. A delete-path run
            carries a null `article_id` (the row it pointed at is gone)
            and names the article in `article_ref`.
        status:
          type: string
          enum: [pending, succeeded, failed]
        outcome:
          type: [string, "null"]
          enum: [created, updated, adopted, noop, skipped, retracted, unsupported]
          description: >
            `adopted` is the lost-create-response idempotency path being
            exercised: a remote page for this article already existed
            (almost always one we made and lost the response for), so it
            was claimed instead of a second copy being published. `skipped`
            is a deliberate decline — the article was no longer published
            when the run ran — and is a success, not a failure.


            PA-269 adds two retraction outcomes. `retracted` means the
            remote copy was unpublished (not deleted). `unsupported` means
            the target offers no non-destructive way to unpublish, so
            nothing was done and the run says so — the alternative,
            deleting a page in the customer's own system because we could
            not do it gently, is not on offer.
        remote_id: { type: [string, "null"] }
        remote_url: { type: [string, "null"] }
        attempt_count: { type: integer }
        max_attempts: { type: integer }
        conversion_notes:
          type: [array, "null"]
          description: >
            What the HTML→target conversion could not carry faithfully —
            e.g. a relative image URL kept as-is because it will not
            resolve on the remote host, or an element flattened to its
            text. Null when the conversion was lossless.
          items: { type: string }
        provider_error:
          type: [string, "null"]
          description: >
            The target's own error code — e.g. `http_409` (Confluence
            version conflict), `rate_limited`. This is the failure mode an
            HTTP-status-only log cannot see.
        last_error: { type: [string, "null"] }
        next_attempt_at: { type: [string, "null"], format: date-time }
        completed_at: { type: [string, "null"], format: date-time }
        created_at: { type: string, format: date-time }

    EcommerceProvider:
      type: object
      additionalProperties: false
      required: [key, label, supports_oauth, handles_inbound_webhooks]
      properties:
        key:
          type: string
          enum: [shopify, woocommerce]
          description: >
            The two adapters ATH-256 shipped. Doc 06 §6 also names Adobe
            Commerce and PrestaShop; they are follow-ups, and absent here
            rather than present and broken.
        label: { type: string }
        supports_oauth:
          type: boolean
          description: >
            True means the connect form collects a shop domain and an
            OAuth access token (Shopify); false means a merchant-supplied
            site URL and a static consumer key/secret (WooCommerce). The
            axis the adapter seam was designed against.
        handles_inbound_webhooks:
          type: boolean
          description: >
            Whether the platform sends signed inbound webhooks. False for
            WooCommerce, deliberately: it is a plugin on the merchant's own
            server with no app-install lifecycle to report, and a dead
            credential surfaces as a 401 on the next fetch instead.

    EcommerceConnection:
      type: object
      additionalProperties: false
      required:
        [id, platform, store_domain, store_url, status, connected_at, store_label,
         remote_account_id, disabled_reason, last_context_fetched_at, revoked_at]
      properties:
        id: { $ref: "#/components/schemas/Ulid" }
        platform:
          type: string
          enum: [shopify, woocommerce]
        store_domain:
          type: string
          description: >
            The store's own host — `acme.myshopify.com` for Shopify, the
            site host for WooCommerce. NOT a secret: it travels in every
            inbound webhook header and is how a delivery is mapped back to
            a connection.
        store_url:
          type: string
          description: The API base the read adapter calls.
        store_label: { type: [string, "null"] }
        remote_account_id: { type: [string, "null"] }
        status:
          type: string
          enum: [connected, disabled, needs_reauth, revoked]
          description: >
            `disabled` is an admin pause (there is no auto-disable — the
            read is synchronous, so an outage is grey panels, not a reason
            to surrender a credential). `needs_reauth` means the store
            rejected the stored credential — a deleted API key or an
            uninstalled app — and only reconnecting helps. `revoked` is a
            completed disconnect.
        disabled_reason: { type: [string, "null"] }
        last_context_fetched_at: { type: [string, "null"], format: date-time }
        connected_at: { type: string, format: date-time }
        revoked_at: { type: [string, "null"], format: date-time }

    EcommerceConnectInput:
      type: object
      required: [platform]
      description: >
        The required fields depend on `platform`. Shopify:
        `store_domain` + `access_token`. WooCommerce: `store_url` +
        `consumer_key` + `consumer_secret`. The wrong set for a platform
        is a 422 naming the missing field. Every credential goes to the
        encrypted vault and is never returned.
      properties:
        platform:
          type: string
          enum: [shopify, woocommerce]
        store_label: { type: [string, "null"], maxLength: 255 }
        store_domain:
          type: string
          maxLength: 255
          description: Shopify only — `acme.myshopify.com`.
        access_token:
          type: string
          maxLength: 500
          description: Shopify only. Written to the vault; never returned.
        store_url:
          type: string
          maxLength: 512
          description: WooCommerce only — an https:// site URL. http is refused.
        consumer_key:
          type: string
          maxLength: 255
          description: WooCommerce only. Written to the vault; never returned.
        consumer_secret:
          type: string
          maxLength: 255
          description: WooCommerce only. Written to the vault; never returned.

    EcommerceConnectionPatchInput:
      type: object
      properties:
        store_label: { type: [string, "null"], maxLength: 255 }
        status:
          type: string
          enum: [connected, disabled]
          description: >
            `disabled` pauses reads without surrendering the credential;
            `connected` resumes. A connection in `needs_reauth` cannot be
            resumed here — only reconnecting fixes a rejected credential.

    EcommerceStoreContext:
      type: object
      additionalProperties: false
      required: [connection_id, platform, store_domain, state, orders]
      description: >
        One store's answer for one customer, fetched LIVE. Nothing here is
        stored: order data is the merchant's, is customer PII, and is read
        on demand and discarded rather than synced into Cairn (which
        makes a GDPR erasure a genuine no-op). A short-lived cache holds
        only this minimised projection.
      properties:
        connection_id: { $ref: "#/components/schemas/Ulid" }
        platform:
          type: string
          enum: [shopify, woocommerce]
        store_domain: { type: string }
        store_label: { type: [string, "null"] }
        state:
          type: string
          enum: [ok, not_found, rate_limited, unavailable]
          description: >
            `ok` — the customer and orders rendered below are current.
            `not_found` — the store answered; this email is not a customer
            there. `rate_limited` — the platform is throttling us and the
            connection is parked; the panel will fill on a later render.
            `unavailable` — the store did not answer, or the connection is
            not currently live. The four are distinct on purpose: an agent
            must never read "we could not ask" as "no orders".
        customer:
          type: [object, "null"]
          properties:
            id: { type: [string, "null"] }
            name: { type: [string, "null"] }
            email: { type: [string, "null"] }
            orders_count: { type: [integer, "null"] }
            total_spent: { type: [string, "null"] }
            currency: { type: [string, "null"] }
        orders:
          type: array
          items: { $ref: "#/components/schemas/EcommerceOrder" }

    EcommerceOrder:
      type: object
      required: [id, item_count]
      description: >
        One order, projected onto a platform-neutral shape. Shopify's
        `financial_status`/`fulfillment_status` and WooCommerce's single
        `status` both map here, so the sidebar renders one shape and never
        learns the two platforms disagree about what an order's state is.
      properties:
        id: { type: string }
        number: { type: [string, "null"] }
        financial_status: { type: [string, "null"] }
        fulfillment_status: { type: [string, "null"] }
        total: { type: [string, "null"] }
        currency: { type: [string, "null"] }
        placed_at: { type: [string, "null"], format: date-time }
        item_count: { type: integer }
        status_url: { type: [string, "null"] }

    SlackNotification:
      type: object
      required: [id, status, attempt_count, created_at]
      properties:
        id: { $ref: "#/components/schemas/Ulid" }
        conversation_id: { $ref: "#/components/schemas/Ulid" }
        channel_id: { type: string }
        thread_ts:
          type: [string, "null"]
          description: >
            The Slack thread this post joined; null on the post that
            STARTS the thread.
        status:
          type: string
          enum: [pending, succeeded, failed]
        attempt_count: { type: integer }
        max_attempts: { type: integer }
        slack_error:
          type: [string, "null"]
          description: >
            Slack's own `error` string from an `ok: false` body — e.g.
            `channel_not_found`, `not_in_channel`, `ratelimited`. This is
            the failure mode an HTTP-status-only log cannot see.
        last_error: { type: [string, "null"] }
        next_attempt_at: { type: [string, "null"], format: date-time }
        delivered_at: { type: [string, "null"], format: date-time }
        created_at: { type: string, format: date-time }
    SamlAcsInput:
      type: object
      required: [SAMLResponse]
      description: >
        ATH-260 — the HTTP-POST binding form body (OASIS SAML 2.0
        bindings §3.5). Field names are the spec's, capitals and all.
      properties:
        SAMLResponse:
          type: string
          description: Base64-encoded `samlp:Response`.
        RelayState:
          type: [string, "null"]
          description: >
            Opaque single-use handle this SP issued at `startSamlLogin`,
            echoed back by the IdP. Absent on an IdP-initiated flow,
            which is only accepted when the connection opts in. It is
            never a URL — an IdP-supplied redirect target is an open
            redirect with extra steps.

    SsoConnection:
      type: object
      additionalProperties: false
      required: [workspace_id, idp_entity_id, idp_sso_url, sp_entity_id, sp_acs_url, enforced, allow_idp_initiated, jit_provisioning, created_at]
      properties:
        workspace_id: { $ref: "#/components/schemas/Ulid" }
        idp_entity_id: { type: string }
        idp_sso_url: { type: string, format: uri }
        sp_entity_id:
          type: string
          description: >
            This workspace's audience value. Unique per workspace — it
            is what stops an assertion minted for another tenant from
            being accepted here even if both tenants use the same IdP.
        sp_acs_url: { type: string, format: uri }
        sp_metadata_url: { type: string, format: uri }
        certificate_fingerprint:
          type: [string, "null"]
          description: >
            SHA-256 fingerprint of the loaded IdP signing certificate,
            colon-separated uppercase hex. Stands in for the
            certificate, which lives in the encrypted vault and is never
            returned.
        certificate_not_after:
          type: [string, "null"]
          format: date-time
          description: >
            Expiry of the loaded certificate. Surfaced because a silently
            expired IdP certificate presents as "SSO stopped working"
            with no other clue.
        enforced:
          type: boolean
          description: >
            When true, members must authenticate through the IdP and
            password login is refused. Workspace `owner`s are exempt —
            see `default_role` and the lockout guard.
        allow_idp_initiated:
          type: boolean
          description: >
            Whether an unsolicited assertion (no `InResponseTo`) is
            accepted. Default false: IdP-initiated SSO has no
            request-binding to replay-check against, so it is opt-in
            rather than assumed.
        jit_provisioning:
          type: boolean
          description: >
            Whether a valid assertion for a non-member creates a
            membership. Off by default — with SCIM configured the IdP is
            the roster and JIT would be a second, weaker provisioning
            path.
        default_role:
          type: string
          description: >
            Role given to a JIT-provisioned member. Constrained to the
            RBAC catalog and may not be `owner`.
        last_successful_login_at:
          type: [string, "null"]
          format: date-time
          description: >
            Evidence the connection works. `enforced` cannot be turned
            on while this is null.
        created_at: { type: string, format: date-time }
        updated_at: { type: [string, "null"], format: date-time }

    SsoConnectionInput:
      type: object
      required: [idp_entity_id, idp_sso_url, idp_certificate]
      properties:
        idp_entity_id: { type: string, maxLength: 1024 }
        idp_sso_url: { type: string, format: uri, maxLength: 2048 }
        idp_certificate:
          type: string
          description: >
            The IdP's X.509 signing certificate, PEM or bare base64.
            Write-only — it is stored in the encrypted vault and never
            read back. Validated as a parseable certificate at write
            time, because the alternative is discovering it was
            unparseable during somebody's first login attempt.
        idp_slo_url: { type: [string, "null"], format: uri, maxLength: 2048 }
        enforced: { type: boolean, default: false }
        allow_idp_initiated: { type: boolean, default: false }
        jit_provisioning: { type: boolean, default: false }
        default_role: { type: string, default: viewer }

    ScimToken:
      type: object
      required: [id, name, token_prefix, created_at, last_used_at, expires_at, revoked_at]
      properties:
        id: { $ref: "#/components/schemas/Ulid" }
        name: { type: string }
        token_prefix:
          type: string
          description: Non-secret leading characters, for telling tokens apart.
        last_used_at: { type: [string, "null"], format: date-time }
        expires_at: { type: [string, "null"], format: date-time }
        revoked_at: { type: [string, "null"], format: date-time }
        created_at: { type: string, format: date-time }

    ScimTokenInput:
      type: object
      required: [name]
      properties:
        name: { type: string, maxLength: 120 }
        expires_at: { type: [string, "null"], format: date-time }

    ScimTokenCreated:
      allOf:
        - $ref: "#/components/schemas/ScimToken"
        - type: object
          required: [token]
          properties:
            token:
              type: string
              description: >
                The plaintext bearer token. Returned exactly once; the
                server keeps only a SHA-256 digest.

    ScimGroupMapping:
      type: object
      required: [external_group, role]
      properties:
        external_group:
          type: string
          description: The IdP group's `displayName`, matched case-insensitively.
        role:
          type: string
          description: An RBAC catalog role. Never `owner`.

    ScimGroupMappingsInput:
      type: object
      required: [mappings]
      properties:
        mappings:
          type: array
          maxItems: 100
          items: { $ref: "#/components/schemas/ScimGroupMapping" }

    ScimError:
      type: object
      required: [schemas, status]
      description: >
        ATH-260 — RFC 7644 §3.12. Deliberately NOT the house `error`
        envelope: SCIM clients parse this shape and only this shape.
        Same exception, and same reasoning, as `OAuthError`.
      properties:
        schemas:
          type: array
          items: { type: string }
        status:
          type: string
          description: HTTP status as a STRING, which is what the RFC says.
        scimType:
          type: [string, "null"]
          description: >
            RFC 7644 §3.12 detail code — `invalidValue`, `uniqueness`,
            `mutability`, `invalidSyntax`, `invalidPath`.
        detail: { type: [string, "null"] }

    ScimListResponse:
      type: object
      required: [schemas, totalResults, Resources]
      properties:
        schemas:
          type: array
          items: { type: string }
        totalResults: { type: integer }
        startIndex: { type: integer }
        itemsPerPage: { type: integer }
        Resources:
          type: array
          description: Capital R. RFC 7644 §3.4.2, not a typo.
          items: { type: object, additionalProperties: true }

    ScimMeta:
      type: object
      readOnly: true
      description: >
        RFC 7643 §3.1 — server-generated resource metadata. `readOnly` is the
        idiomatic answer to "same schema, both directions": SCIM genuinely
        PUTs and GETs the same resource, and the standard marks `meta` as
        read-only rather than defining a separate input resource. The SCIM
        controllers never read `meta` from a request body, so this documents
        behaviour that already holds rather than changing any. See PA-322.
      properties:
        resourceType: { type: string }
        created: { type: string, format: date-time }
        lastModified: { type: string, format: date-time }
        location: { type: string }

    ScimUser:
      type: object
      required: [schemas, userName]
      description: RFC 7643 §4.1, restricted to the attributes Cairn stores.
      properties:
        schemas:
          type: array
          items: { type: string }
        id: { type: string }
        externalId:
          type: [string, "null"]
          description: The IdP's own identifier for this person.
        userName:
          type: string
          description: >
            Treated as the email address, which is what Okta, Entra and
            Google Workspace all send. It is the join key to an Cairn
            user.
        name:
          type: object
          properties:
            givenName: { type: [string, "null"] }
            familyName: { type: [string, "null"] }
            formatted: { type: [string, "null"] }
        displayName: { type: [string, "null"] }
        emails:
          type: array
          items:
            type: object
            properties:
              value: { type: string }
              primary: { type: boolean }
              type: { type: [string, "null"] }
        active:
          type: boolean
          description: >
            False means DEPROVISIONED, not dormant. See
            `deprovisionScimUser` for what that revokes.
        roles:
          type: array
          description: >
            Read-only echo of the effective Cairn role. Writes here are
            ignored — role comes from group mapping, so that there is
            one path to a role rather than two that can disagree.
          items:
            type: object
            properties:
              value: { type: string }
        meta: { $ref: "#/components/schemas/ScimMeta" }

    ScimGroup:
      type: object
      required: [schemas, displayName]
      description: RFC 7643 §4.2.
      properties:
        schemas:
          type: array
          items: { type: string }
        id: { type: string }
        externalId: { type: [string, "null"] }
        displayName:
          type: string
          description: >
            Matched against the configured group-to-role mappings. An
            unmatched name is stored and confers nothing.
        members:
          type: array
          items:
            type: object
            properties:
              value:
                type: string
                description: The SCIM id of a provisioned user in this workspace.
              display: { type: [string, "null"] }
        meta: { $ref: "#/components/schemas/ScimMeta" }

    ScimPatchRequest:
      type: object
      required: [schemas, Operations]
      description: RFC 7644 §3.5.2.
      properties:
        schemas:
          type: array
          items: { type: string }
        Operations:
          type: array
          description: Capital O. RFC 7644 §3.5.2, also not a typo.
          items:
            type: object
            required: [op]
            properties:
              op:
                type: string
                description: >
                  `add`, `remove` or `replace`, matched
                  case-insensitively because IdPs disagree about the
                  casing and the RFC does not require them to.
              path: { type: [string, "null"] }
              value: {}

    ScimServiceProviderConfig:
      type: object
      required: [schemas]
      description: RFC 7643 §5.
      properties:
        schemas:
          type: array
          items: { type: string }
        documentationUri: { type: string }
        patch:
          type: object
          properties:
            supported: { type: boolean }
        bulk:
          type: object
          properties:
            supported: { type: boolean }
            maxOperations: { type: integer }
            maxPayloadSize: { type: integer }
        filter:
          type: object
          properties:
            supported: { type: boolean }
            maxResults: { type: integer }
        changePassword:
          type: object
          properties:
            supported: { type: boolean }
        sort:
          type: object
          properties:
            supported: { type: boolean }
        etag:
          type: object
          properties:
            supported: { type: boolean }
        authenticationSchemes:
          type: array
          items:
            type: object
            properties:
              type: { type: string }
              name: { type: string }
              description: { type: string }
        meta: { $ref: "#/components/schemas/ScimMeta" }
