openapi: 3.1.0
info:
  title: Dialer Digital core API (v1)
  version: 0.36.0
  summary: Compliance-first outbound dialer control plane — the authenticated /v1 surface.
  description: |
    Formal reference of the `/v1` control-plane API served by `Dialer.API.Router`
    (Bandit, port 4000). Source of truth: `lib/dialer/api/v1_router.ex` (tenant
    surface), `lib/dialer/api/admin_router.ex` (platform-admin surface),
    `lib/dialer/api/auth_router.ex` (dashboard login and password recovery — the
    only unauthenticated `/v1` surface) and `lib/dialer/api/sms_webhook_router.ex` (public SMS
    webhooks, described under `webhooks`). CI enforces
    `tools/check_openapi_drift.py`: every route in the routers must appear here
    (method + path) and vice versa, and `info.version` must equal the
    `@version` in `mix.exs`.

    ## Conventions

    - JSON in / JSON out. Request bodies above 8 MiB are rejected.
    - Errors always use the envelope `{"error": {"code": "...", "message": "..."}}`.
      Branch on `code` (machine-readable), never on `message`.
    - Every response carries `X-Request-ID` (yours is propagated when sane).
    - Timestamps are RFC 3339 / ISO-8601 (UTC). Times of day are `HH:MM:SS`.
    - Decimal fields (pacing ratios, thresholds) are rendered as JSON strings.

    ## Multi-tenancy and 404 semantics

    Every route runs with the tenant resolved from the bearer key; all data access
    flows through Postgres row-level security (RLS). The API never accepts a tenant
    id from path, query or body. Cross-tenant ids read as `404 not_found` because
    RLS returns zero rows — existence is never leaked (no 403 for foreign ids), and
    malformed (non-UUID) ids are the same `404`.

    ## Canonical errors

    - `401 unauthorized` — missing/unknown/revoked API key. `403 forbidden` — suspended tenant.
    - `404 not_found` — unknown, malformed or cross-tenant id (RLS; see above).
    - `422 compliance_blocked` — the compliance engine refused the operation; the
      body carries `gate` (which gate refused) and `decision_id` (citable against
      the audit chain). Do NOT retry unchanged — the block is evidence, not an error.
    - `429 sms_spend_cap_exceeded` — month-to-date SMS budget brake; retryable only
      after a cap raise or the month rollover. `429 throttled` — rate brake; retry
      later with backoff. No `Retry-After` header is set.
    - `502 provider_error` / `502 dial_error` / `502 sbc_error` — an upstream
      (SMS provider, switch, SIP edge) rejected the operation.

    Out of scope here (documented in `API.md`): `GET /healthz` (no auth),
    `GET /v1/ws` websocket live floor (upgrades before this pipeline; auth via
    an ephemeral one-shot `?ticket=` minted by `POST /v1/ws/ticket`, never a
    long-lived bearer), and `GET /metrics` (Prometheus, port 9568).
  contact:
    name: Dialer Digital
    url: https://github.com/DialerDigital/core
  license:
    name: Proprietary — Dialer Digital
    url: https://github.com/DialerDigital/core
servers:
  - url: http://127.0.0.1:4000
    description: Local development (compose stack; `API_PORT`, fallback `PORT`).
  - url: https://{host}
    description: Deployment-specific ingress in front of the core Service.
    variables:
      host:
        default: api.usa.dialerdigital.com
security:
  - tenantApiKey: []
tags:
  - name: identity
    description: Authenticated tenant, account settings.
  - name: auth
    description: "Dashboard login (email + password -> a revocable ddu_ session; sha256 at rest, plaintext shown once) and password recovery (a one-shot, expiring link — the request half never reveals whether an address has an account)."
  - name: users
    description: Dashboard user accounts (email + password; disabled, never deleted).
  - name: api-keys
    description: Per-tenant API keys (sha256 at rest; plaintext shown once).
  - name: widgets
    description: "Embeddable agent-widget lifecycle — ddw_ browser tokens + the origin allowlist."
  - name: campaigns
    description: Campaign CRUD, lifecycle, manual dial, rewind.
  - name: debts
    description: Bulk lead import, debt detail, per-debt CDR, defense packet.
  - name: call-attempts
    description: Append-only CDR query + typed dispositions.
  - name: callbacks
    description: Consumer-agreed redial appointments.
  - name: promises
    description: Promise-to-pay pipeline.
  - name: agents
    description: Seat roster, presence, softphone checkin/checkout.
  - name: supervision
    description: Supervisor audio (listen/whisper/barge/takeover), evidence-first.
  - name: calls
    description: Live-call control verbs (hold/unhold/mute/unmute/hangup), evidence-first.
  - name: stats
    description: Operational counters, blocked-dial rollups, usage metering.
  - name: billing
    description: Invoiceable line items.
  - name: reports
    description: Packaged compliance artifacts.
  - name: compliance
    description: "Suppression data the tenant owns: its internal do-not-call list, uploaded as CSV into the same list the pre-dial gate reads."
  - name: dids
    description: DID registry + CDR-computed health.
  - name: carriers
    description: SIP trunk registry (BYOC/house) + SBC provisioning.
  - name: sms
    description: Gated one-off SMS send + unified ledger.
  - name: sms-providers
    description: Per-tenant/region SMS routing registry.
  - name: webhooks
    description: "Outbound webhooks for CRMs: the tenant's endpoint registry, its subscribed events and the signing secret (shown once; rotation with a window)."
  - name: inbound-routes
    description: Inbound DID routing + SBC provisioning.
  - name: recordings
    description: "Call-recording evidence: retention-locked metadata, signed playback, legal hold, bulk export, per-tenant policy."
  - name: admin
    description: Platform-admin surface (separate trust domain, `ADMIN_API_TOKEN`).
  - name: voice-ai
    description: "AI voice agents: builder + versioned policy, live conversations, transcripts/outcomes, supervisor takeover, the per-tenant AI-dialing kill-switch and today's metrics."
paths:
  # ── identity ────────────────────────────────────────────────────────────
  /v1/me:
    get:
      operationId: getMe
      tags: [identity]
      summary: Authenticated tenant (+ user for a login session)
      description: |
        Returns the tenant resolved from the bearer. When the bearer is a
        `ddu_` login session (email+password dashboard auth), the response also
        carries the `user` behind the console; for a `dd_` machine key the
        tenant-only shape is unchanged.
      security:
        - tenantApiKey: []
        - userSessionToken: []
      responses:
        "200":
          description: The authenticated tenant (and user, for a ddu_ bearer).
          content:
            application/json:
              schema:
                type: object
                required: [tenant]
                properties:
                  tenant: { $ref: "#/components/schemas/Tenant" }
                  user: { $ref: "#/components/schemas/User" }
              example:
                tenant:
                  id: 0d4f4f9e-1f2a-4b53-9d3c-8a5e2f7b1c10
                  name: Acme Collections
                  status: active
                  retention_months: 84
                  seats: 10
                  tier: scale
                  voice_ai_enabled: true
                  ai_monthly_budget_usd: 500
                  ai_max_call_seconds: 600
                  ai_max_call_seconds_effective: 600
                  sms_monthly_budget_usd: 250
                  created_at: "2026-06-01T12:00:00Z"
                  updated_at: "2026-06-20T09:30:00Z"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
  # ── dashboard login (Dialer.API.AuthRouter — the unauthenticated surface) ─
  /v1/auth/login:
    post:
      operationId: login
      tags: [auth]
      summary: Email + password login
      description: |
        Mints a revocable `ddu_` dashboard session. Enumeration-free: unknown
        email, wrong password and a disabled user are ONE identical `401` at
        the same PBKDF2 cost; only proven-correct credentials of a suspended
        tenant/account earn the `403`. Rate-limited per email (fixed window)
        on top of the hashing work factor. The plaintext `token` is shown
        exactly once — only its sha256 is stored.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, password]
              properties:
                email: { type: string, format: email }
                password: { type: string, format: password }
            example:
              email: admin@local.test
              password: local-dev-password
      responses:
        "200":
          description: Session minted; `token` is the only time the plaintext is visible.
          content:
            application/json:
              schema:
                type: object
                required: [token, session, user, tenant]
                properties:
                  token:
                    type: string
                    description: "Plaintext ddu_ bearer, shown exactly once. Fixed expiry (no sliding renew): re-login when it ends."
                  session: { $ref: "#/components/schemas/UserSession" }
                  user: { $ref: "#/components/schemas/User" }
                  tenant: { $ref: "#/components/schemas/Tenant" }
              example:
                token: ddu_example_plaintext_shown_once
                session:
                  id: 2f6a1c58-9d43-4b1a-8e4f-6c1a2b3d4e5f
                  user_id: 7c1d2e3f-4a5b-4c6d-8e9f-0a1b2c3d4e5f
                  expires_at: "2026-07-10T22:00:00Z"
                  revoked_at: null
                  created_at: "2026-07-10T10:00:00Z"
                user:
                  id: 7c1d2e3f-4a5b-4c6d-8e9f-0a1b2c3d4e5f
                  email: admin@local.test
                  name: Local Dev Admin
                  status: active
                  password_updated_at: "2026-07-01T09:00:00Z"
                  last_login_at: "2026-07-10T10:00:00Z"
                  created_at: "2026-07-01T09:00:00Z"
                  updated_at: "2026-07-10T10:00:00Z"
                tenant:
                  id: 0d4f4f9e-1f2a-4b53-9d3c-8a5e2f7b1c10
                  name: Acme Collections
                  status: active
        "400": { $ref: "#/components/responses/BadRequest" }
        "401":
          description: Invalid credentials — unknown email, wrong password and disabled user are indistinguishable.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
              example:
                error: { code: unauthorized, message: invalid email or password }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/LoginRateLimited" }
  /v1/auth/logout:
    post:
      operationId: logout
      tags: [auth]
      summary: Revoke the presented session
      description: |
        Revokes the `ddu_` session carried in the `Authorization` header.
        IDEMPOTENT and enumeration-free: any well-formed bearer is `204`
        whether or not it named a live session; only a missing/malformed
        header is `401`. Sessions are revoked, never deleted.
      security:
        - userSessionToken: []
      responses:
        "204":
          description: The session (if any) is revoked.
        "401": { $ref: "#/components/responses/Unauthorized" }
  /v1/auth/password-reset:
    post:
      operationId: requestPasswordReset
      tags: [auth]
      summary: Request a one-shot password-reset link
      description: |
        Mails a single-use, expiring link to the address IF it belongs to an
        active dashboard account, and says NOTHING about whether it does.

        The answer is ALWAYS `202` with the SAME body — byte for byte —
        whether the address has an account, has none, or belongs to a disabled
        user. Anything else would turn this unauthenticated route into a
        customer enumerator. Being rate-limited answers that same `202` too; it
        simply sends no mail, because a `429` would be an oracle of its own.
        Only a malformed body (no `email`, or a non-string) is a `400`.

        Requesting again invalidates any previous live link for that account:
        exactly ONE link is ever redeemable. A delivery failure does NOT change
        the `202`, and leaves the token valid so the person can retry.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email: { type: string, format: email }
              additionalProperties: false
            example:
              email: admin@local.test
      responses:
        "202":
          description: Accepted. Identical for every outcome (see the description).
          content:
            application/json:
              schema:
                type: object
                required: [status, message]
                properties:
                  status: { type: string, enum: [accepted] }
                  message: { type: string }
                additionalProperties: false
              example:
                status: accepted
                message: if that address has an account, a reset link is on its way
        "400": { $ref: "#/components/responses/BadRequest" }
  /v1/auth/password-reset/confirm:
    post:
      operationId: confirmPasswordReset
      tags: [auth]
      summary: Redeem a reset link and set the new password
      description: |
        Burns the one-shot token, writes the new password and REVOKES every
        live `ddu_` session of that user — all in ONE transaction, so no
        session minted under the old password survives the change. The person
        logs in again with the new one.

        Fail-closed and single-valued: an unknown, tampered, expired,
        superseded or already-redeemed token are ONE identical `401`. The
        password policy (12–128 characters, the same one `POST /v1/users`
        applies) is checked BEFORE the token is touched, so a too-short
        password answers `422` with the link still redeemable.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [token, new_password]
              properties:
                token:
                  type: string
                  description: The plaintext from the e-mailed link. Single use.
                new_password: { type: string, format: password, minLength: 12, maxLength: 128 }
              additionalProperties: false
            example:
              token: ddr_example_plaintext_from_the_link
              new_password: an-even-better-password
      responses:
        "200":
          description: Password written, token burned, every live session revoked.
          content:
            application/json:
              schema:
                type: object
                required: [status]
                properties:
                  status: { type: string, enum: [ok] }
                additionalProperties: false
              example:
                status: ok
        "400": { $ref: "#/components/responses/BadRequest" }
        "401":
          description: Unknown, tampered, expired, superseded or already-redeemed token — indistinguishable by design.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
              example:
                error: { code: unauthorized, message: invalid or expired token }
        "422": { $ref: "#/components/responses/Unprocessable" }
  /v1/auth/agent-login:
    post:
      operationId: agentLogin
      tags: [auth]
      summary: Widget agent login (email + password -> a ddw_ token)
      description: |
        Signs an AGENT into the embeddable widget (embedded in the customer's
        CRM) with their own credential, and mints the restricted `ddw_` browser
        token bound to that agent — so call actions are attributable to the
        person, in real time, WITHOUT the customer ever handling the tenant
        `dd_` key or a dashboard `ddu_` session.

        `tenant_id` is the widget's PUBLIC workspace id (embedded at onboarding;
        a non-secret uuid, like a publishable key) — it SELECTS the tenant; the
        `Origin` NEVER resolves a tenant. The mandatory `Origin` header is the
        WIDGET-HOST — the browser document that serves the frame, a DEPLOY
        CONSTANT (`WIDGET_HOST_ORIGINS` / `Dialer.Widgets.host_origins/0`) — and
        must value-match one of the configured widget-host origins. That is
        DISTINCT from the tenant's per-tenant EMBED allowlist (`widget_origins`,
        which governs CSP `frame-ancestors` / postMessage); the two allowlists
        deliberately diverge. Fail-closed: an empty widget-host set authenticates
        no one. The minted `ddw_` is UNPINNED — the widget-host Origin is
        re-value-matched on every subsequent widget request; the token is not
        bound to a CRM origin. Cross-origin (CORS): the `Origin` is reflected;
        the hard boundary is the widget-host match.

        Enumeration- and timing-flat: an unknown tenant, a non-widget-host
        origin, unknown email, wrong password and a login-not-enabled agent are
        ONE identical `401` at the same PBKDF2 cost; only proven-correct
        credentials of a suspended tenant/account earn the `403`. Per-(tenant,
        email) fixed-window brake. Flag-gated on the widget lane
        (`:widget_auth_enabled`): while dark, this route is `404`.

        The minted `ddw_` reaches ONLY the default-deny widget surface
        (`WidgetAllowlist`): the agent's own call-control verbs, disposition,
        dial, callbacks, and the token's own renew/introspect — never the tenant
        management surface.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [tenant_id, email, password]
              properties:
                tenant_id:
                  type: string
                  format: uuid
                  description: The widget's public workspace id (non-secret).
                email: { type: string, format: email }
                password: { type: string, format: password }
            example:
              tenant_id: 0d4f4f9e-1f2a-4b53-9d3c-8a5e2f7b1c10
              email: agent@acme-collections.test
              password: the-agents-passphrase
      responses:
        "200":
          description: Logged in; `token` (the ddw_ plaintext) is shown exactly once.
          content:
            application/json:
              schema:
                type: object
                required: [token, widget_token, agent, tenant, origins]
                properties:
                  token:
                    type: string
                    description: "Plaintext ddw_ browser token, shown exactly once."
                  widget_token: { $ref: "#/components/schemas/WidgetToken" }
                  agent: { $ref: "#/components/schemas/Agent" }
                  tenant: { $ref: "#/components/schemas/Tenant" }
                  origins:
                    type: array
                    items: { type: string }
                    description: The tenant's enabled EMBED origins (`widget_origins`) for the frame's CSP `frame-ancestors` / postMessage peer — NOT the auth boundary (that is the widget-host Origin).
        "400": { $ref: "#/components/responses/BadRequest" }
        "401":
          description: Invalid login — unknown tenant/email, wrong password, a non-widget-host Origin and a login-disabled agent are indistinguishable.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
              example:
                error: { code: unauthorized, message: invalid email or password }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404":
          description: The widget lane is disabled (`:widget_auth_enabled` off).
        "429": { $ref: "#/components/responses/LoginRateLimited" }
  /v1/auth/widget-config:
    get:
      operationId: getWidgetConfig
      tags: [auth]
      summary: Public widget bootstrap config (the tenant's enabled origins)
      description: |
        The tenant's ENABLED EMBED origins (`widget_origins`) — the per-tenant
        allowlist of CRM origins that may embed this tenant's widget. PUBLIC
        (`security: []`): these strings already ship inside CSP headers. It
        feeds the two EMBED-side gates from ONE source: the frame host's
        `Content-Security-Policy: frame-ancestors` (who may EMBED the widget)
        and the frame's postMessage peer-origin validation (who it will TALK
        to). This is the EMBED boundary ONLY — DISTINCT from the AUTH boundary,
        which is the widget-host Origin (`WIDGET_HOST_ORIGINS`, a deploy
        constant) re-value-matched on every `ddw_` request; the two allowlists
        deliberately diverge. CORS-reflected like agent-login; cacheable 60s. An
        unknown `tenant_id` answers `origins: []` — no tenant-existence oracle.
        Flag-gated on the widget lane (`:widget_auth_enabled`): while dark,
        this route is `404`.
      security: []
      parameters:
        - name: tenant_id
          in: query
          required: true
          schema: { type: string, format: uuid }
          description: The widget's public workspace id (non-secret).
      responses:
        "200":
          description: The tenant's enabled origins (possibly empty).
          content:
            application/json:
              schema:
                type: object
                required: [origins]
                properties:
                  origins:
                    type: array
                    items: { type: string }
              example:
                origins: ["https://crm.acme-collections.test"]
        "400": { $ref: "#/components/responses/BadRequest" }
        "404":
          description: The widget lane is disabled (`:widget_auth_enabled` off).
  /v1/me/password:
    post:
      operationId: changeOwnPassword
      tags: [auth]
      summary: Rotate the caller's own password
      description: |
        Requires a `ddu_` login session (a `dd_` machine key has no person to
        rotate — `403`). Verifies the current password, stores the new hash and
        revokes every OTHER live session of the user; the session performing
        the rotation survives.

        Because this route verifies the caller's REAL password, wrong
        confirmations are rate-limited per USER (`429`): a stolen session cannot
        be used to read the password out by brute force. A correct confirmation
        is never counted, so normal rotation is unaffected.
      security:
        - userSessionToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [current_password, new_password]
              properties:
                current_password: { type: string, format: password }
                new_password:
                  type: string
                  format: password
                  minLength: 12
                  maxLength: 128
      responses:
        "204":
          description: Password rotated; other sessions revoked.
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422":
          description: The current password is incorrect.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
              example:
                error: { code: unprocessable, message: current password is incorrect }
        "429": { $ref: "#/components/responses/PasswordConfirmThrottled" }
  /v1/tenant:
    patch:
      operationId: updateTenant
      tags: [identity]
      summary: Self-service account settings
      description: |
        Only `name` is tenant-editable. The billing-bearing fields (`tier`,
        `voice_ai_enabled`, `seats`, spend caps) live on the admin surface
        (`PATCH /v1/admin/tenants/{id}`) — letting a tenant edit them with its own
        bearer is a direct under-pay vector. Other body keys are silently ignored.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
            example:
              name: Acme Collections LLC
      responses:
        "200":
          description: Updated tenant.
          content:
            application/json:
              schema:
                type: object
                required: [tenant]
                properties:
                  tenant: { $ref: "#/components/schemas/Tenant" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
  /v1/tenants:
    get:
      operationId: listTenants
      tags: [identity]
      summary: List tenants (always one element)
      description: |
        Docs parity: API keys are tenant-scoped, so the list contains exactly the
        authenticated tenant.
      responses:
        "200":
          description: One-element list (own tenant).
          content:
            application/json:
              schema:
                type: object
                required: [tenants]
                properties:
                  tenants:
                    type: array
                    items: { $ref: "#/components/schemas/Tenant" }
        "401": { $ref: "#/components/responses/Unauthorized" }
  /v1/tenants/{id}:
    get:
      operationId: getTenant
      tags: [identity]
      summary: Fetch own tenant by id
      description: Own tenant id → the tenant. ANY other id → `404` (never a 403; no existence leak).
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: The tenant.
          content:
            application/json:
              schema:
                type: object
                required: [tenant]
                properties:
                  tenant: { $ref: "#/components/schemas/Tenant" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
  # ── api keys ────────────────────────────────────────────────────────────
  /v1/api_keys:
    get:
      operationId: listApiKeys
      tags: [api-keys]
      summary: List API keys
      description: Ids, labels and revocation timestamps only — hashes are never exposed.
      responses:
        "200":
          description: The tenant's keys.
          content:
            application/json:
              schema:
                type: object
                required: [api_keys]
                properties:
                  api_keys:
                    type: array
                    items: { $ref: "#/components/schemas/ApiKey" }
              example:
                api_keys:
                  - id: 7a3f0f7e-52a1-4f3d-9f9d-27e5a1b9c001
                    label: onboarding
                    revoked_at: null
                    created_at: "2026-06-01T12:00:00Z"
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      operationId: createApiKey
      tags: [api-keys]
      summary: Mint an API key
      description: |
        The plaintext token (`dd_...`) is returned ONCE and only its sha256 is
        stored. Rotate by minting a new key and revoking the old one.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                label: { type: string, description: "Free-form label (default \"\")." }
            example:
              label: postman-smoke-1751600000
      responses:
        "201":
          description: Key minted; `token` is the only time the plaintext is visible.
          content:
            application/json:
              schema:
                type: object
                required: [api_key, token]
                properties:
                  api_key: { $ref: "#/components/schemas/ApiKey" }
                  token:
                    type: string
                    description: Plaintext bearer token, shown exactly once.
              example:
                api_key:
                  id: 9a1c2b3d-4e5f-4a6b-8c7d-0e1f2a3b4c5d
                  label: postman-smoke-1751600000
                  revoked_at: null
                  created_at: "2026-07-05T10:00:00Z"
                token: dd_example_plaintext_shown_once
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
  /v1/api_keys/{id}:
    delete:
      operationId: revokeApiKey
      tags: [api-keys]
      summary: Revoke an API key
      description: Sets `revoked_at`; keys are never deleted. Idempotent revocation semantics.
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: The revoked key.
          content:
            application/json:
              schema:
                type: object
                required: [api_key]
                properties:
                  api_key: { $ref: "#/components/schemas/ApiKey" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
  # ── dashboard users (email + password accounts) ──────────────────────────
  /v1/ws/ticket:
    post:
      operationId: mintWsTicket
      tags: [ws]
      summary: Mint an ephemeral single-use WebSocket ticket
      description: |
        Mints the ONLY credential `GET /v1/ws` accepts (MT-SEC-05 / MT-SEC-10).

        Browsers cannot set headers on a WebSocket handshake, so the credential
        must ride the query string — where it lands in access logs, history and
        `Referer`. This endpoint makes what rides there worthless: the ticket
        expires in seconds and is BURNED on first redemption. The long-lived
        bearer stays where it can go: this request's `Authorization` header.

        The ticket inherits the EXACT principal of the bearer that minted it —
        a `ddw_` agent bearer mints an agent-lane ticket, never a tenant one.
        Lane is encoded in the prefix (`ddta_` agent, `ddtt_` tenant) so the
        public edge can admit only the agent lane without a database lookup.

        Redemption is single-use: a second `GET /v1/ws` with the same ticket
        is rejected, as is an expired one. Both answer the same 401 as an
        unknown ticket — the client learns nothing from the difference.
      responses:
        "201":
          description: A freshly minted ticket. It is valid for `expires_in` seconds and for ONE handshake.
          content:
            application/json:
              schema:
                type: object
                required: [ticket, expires_at, expires_in]
                properties:
                  ticket:
                    type: string
                    description: Present it as `GET /v1/ws?ticket=…`. Never reusable.
                    example: ddtt_8Zr3kQ0m2sVx1yPd7La9WfHnB6TcEjRu4KgMoZvQiXs
                  expires_at:
                    type: string
                    format: date-time
                    description: Absolute expiry (RFC-3339).
                  expires_in:
                    type: integer
                    description: Seconds of life. Deliberately small.
                    example: 30
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "503":
          description: The ticket could not be minted (storage unavailable). Fail-closed — no socket is opened.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
              example:
                error: { code: unavailable, message: "could not mint a websocket ticket" }

  /v1/users:
    get:
      operationId: listUsers
      tags: [users]
      summary: List dashboard users
      description: Emails, names, status and login timestamps — password hashes are never exposed.
      responses:
        "200":
          description: The tenant's dashboard users.
          content:
            application/json:
              schema:
                type: object
                required: [users]
                properties:
                  users:
                    type: array
                    items: { $ref: "#/components/schemas/User" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      operationId: createUser
      tags: [users]
      summary: Create a dashboard user
      description: |
        Bootstrap runs with the `dd_` machine key (it creates the FIRST user);
        after that any `ddu_` session can manage users (all users are console
        admins day-1). The email is the GLOBAL login identifier (stored
        lowercase, unique across tenants — `409` on any duplicate). The
        password never persists in plaintext (PBKDF2-SHA512 at rest) and is
        never echoed back. `tenant_id` is never read from the body.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, password]
              properties:
                email: { type: string, format: email }
                name: { type: string }
                password:
                  type: string
                  format: password
                  minLength: 12
                  maxLength: 128
            example:
              email: ops@acme-collections.test
              name: Ops Admin
              password: a-long-passphrase-here
      responses:
        "201":
          description: The created user (no secret material).
          content:
            application/json:
              schema:
                type: object
                required: [user]
                properties:
                  user: { $ref: "#/components/schemas/User" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
  /v1/users/{id}:
    delete:
      operationId: disableUser
      tags: [users]
      summary: Disable a user (never deletes)
      description: |
        Sets `status: disabled` AND revokes every live session the user holds
        (their bearer dies at the next request). Idempotent. Users are audit
        evidence — rows never delete; re-enable via `POST /v1/users/{id}/enable`.
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: The disabled user.
          content:
            application/json:
              schema:
                type: object
                required: [user]
                properties:
                  user: { $ref: "#/components/schemas/User" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /v1/users/{id}/enable:
    post:
      operationId: enableUser
      tags: [users]
      summary: Re-enable a disabled user
      description: Sessions revoked at disable STAY revoked — the user logs in fresh.
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: The re-enabled user.
          content:
            application/json:
              schema:
                type: object
                required: [user]
                properties:
                  user: { $ref: "#/components/schemas/User" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  # ── widget tokens + origins (CTI-4) ─────────────────────────────────────
  /v1/widget_tokens:
    get:
      operationId: listWidgetTokens
      tags: [widgets]
      summary: List widget tokens
      description: Ids, agent, labels, expiry and revocation only — the token hash is never exposed.
      responses:
        "200":
          description: The tenant's widget tokens.
          content:
            application/json:
              schema:
                type: object
                required: [widget_tokens]
                properties:
                  widget_tokens:
                    type: array
                    items: { $ref: "#/components/schemas/WidgetToken" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      operationId: mintWidgetToken
      tags: [widgets]
      summary: Mint a widget token
      description: |
        Mints a `ddw_` browser token bound to one agent. The plaintext is returned
        ONCE; only its sha256 is stored. TTLs are server-authoritative (never client
        input). Rate-limited per tenant.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [agent_id]
              properties:
                agent_id: { type: string, format: uuid, description: "The agent this token acts as." }
                label: { type: string, description: "Free-form label (default \"\")." }
                allowed_origin_id:
                  type: string
                  format: uuid
                  description: "Optional — pin to a single `widget_origins` row (narrows the EMBED origins surfaced to the frame; NOT the auth boundary, which is the widget-host Origin). Agent-login mints UNPINNED."
            example:
              agent_id: 7a3f0f7e-52a1-4f3d-9f9d-27e5a1b9c001
              label: crm-embed
      responses:
        "201":
          description: Token minted; `token` is the only time the plaintext is visible.
          content:
            application/json:
              schema:
                type: object
                required: [widget_token, token]
                properties:
                  widget_token: { $ref: "#/components/schemas/WidgetToken" }
                  token: { type: string, description: "Plaintext ddw_ token, shown exactly once." }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/WidgetRateLimited" }
  /v1/widget_tokens/{id}:
    delete:
      operationId: revokeWidgetToken
      tags: [widgets]
      summary: Revoke a widget token
      description: |
        Sets `revoked_at`; tokens are never deleted. Idempotent.

        ALSO tears the agent's SIP endpoint down (NR-02): deletes the ephemeral
        `agentcred` credential and the live `usrloc` binding. Without it a
        revoked token left the softphone able to REGISTER and carry calls until
        the credential's 24h autoexpire. The DB revocation is durable and
        happens regardless; `sip_teardown` reports whether the SIP half
        succeeded, so a `200` never implies an eviction that did not happen.
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: The revoked token, plus the outcome of the SIP teardown.
          content:
            application/json:
              schema:
                type: object
                required: [widget_token, sip_teardown]
                properties:
                  widget_token: { $ref: "#/components/schemas/WidgetToken" }
                  sip_teardown: { $ref: "#/components/schemas/SipTeardown" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /v1/widget_tokens/renew:
    post:
      operationId: renewWidgetSession
      tags: [widgets]
      summary: Renew the caller's own widget session
      description: |
        Slides the AUTHENTICATED ddw_ token's expiry forward (never past the session
        cap). Authenticated by the ddw_ itself; operates ONLY on the caller's own
        token (no id from path/body). Rate-limited per token.
      security:
        - widgetSessionToken: []
      responses:
        "200":
          description: The renewed token.
          content:
            application/json:
              schema:
                type: object
                required: [widget_token]
                properties:
                  widget_token: { $ref: "#/components/schemas/WidgetToken" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/WidgetRateLimited" }
  /v1/widget_tokens/introspect:
    post:
      operationId: introspectWidgetSession
      tags: [widgets]
      summary: The caller's own widget session context
      description: |
        Returns the authenticated ddw_ session's tenant/agent, its allowlisted
        origins (for the frame CSP) and its session clocks. Authenticated by the
        ddw_ itself; tenant-scoped, no enumeration.
      security:
        - widgetSessionToken: []
      responses:
        "200":
          description: The caller's live session context.
          content:
            application/json:
              schema:
                type: object
                required: [widget_session]
                properties:
                  widget_session: { $ref: "#/components/schemas/WidgetSession" }
        "401": { $ref: "#/components/responses/Unauthorized" }
  /v1/widget_origins:
    get:
      operationId: listWidgetOrigins
      tags: [widgets]
      summary: List allowlisted origins
      responses:
        "200":
          description: The tenant's allowlisted origins (enabled + disabled).
          content:
            application/json:
              schema:
                type: object
                required: [widget_origins]
                properties:
                  widget_origins:
                    type: array
                    items: { $ref: "#/components/schemas/WidgetOrigin" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      operationId: addWidgetOrigin
      tags: [widgets]
      summary: Add an allowlisted origin
      description: The raw origin is normalized to canonical https form; a malformed value is a 400.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [origin]
              properties:
                origin: { type: string, description: "A web origin, e.g. https://crm.example.com." }
                label: { type: string }
            example:
              origin: https://crm.example.com
      responses:
        "201":
          description: The added origin (canonical form).
          content:
            application/json:
              schema:
                type: object
                required: [widget_origin]
                properties:
                  widget_origin: { $ref: "#/components/schemas/WidgetOrigin" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/Unprocessable" }
  /v1/widget_origins/{id}:
    delete:
      operationId: disableWidgetOrigin
      tags: [widgets]
      summary: Disable an allowlisted origin
      description: Soft-disable (dropped from the embed allowlist — stops framing this CRM via `frame-ancestors`/postMessage; does NOT block agent login, which gates on the widget-host Origin). Origins are never deleted.
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: The disabled origin.
          content:
            application/json:
              schema:
                type: object
                required: [widget_origin]
                properties:
                  widget_origin: { $ref: "#/components/schemas/WidgetOrigin" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /v1/widget_origins/{id}/enable:
    post:
      operationId: enableWidgetOrigin
      tags: [widgets]
      summary: Re-enable an allowlisted origin
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: The re-enabled origin.
          content:
            application/json:
              schema:
                type: object
                required: [widget_origin]
                properties:
                  widget_origin: { $ref: "#/components/schemas/WidgetOrigin" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  # ── campaigns ───────────────────────────────────────────────────────────
  /v1/campaigns:
    get:
      operationId: listCampaigns
      tags: [campaigns]
      summary: List campaigns
      responses:
        "200":
          description: All campaigns of the tenant.
          content:
            application/json:
              schema:
                type: object
                required: [campaigns]
                properties:
                  campaigns:
                    type: array
                    items: { $ref: "#/components/schemas/Campaign" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      operationId: createCampaign
      tags: [campaigns]
      summary: Create a campaign (state `draft`)
      description: |
        `campaign_type` selects the pacing profile (`predictive` default;
        `preview`/`manual` are the human-initiated modes `POST /v1/campaigns/{id}/dial`
        requires; `sms_blast` is the `:sms`-channel type). `sms_body` is REQUIRED for
        `sms_blast` and must contain the `STOP` opt-out notice (Reg F §1006.6(e));
        it supports `{{debt_ref}}`, `{{consumer_ref}}`, `{{account_number}}`,
        `{{amount}}`, `{{state}}` merge vars. Every id in `caller_id_pool` must exist
        in the tenant's `/v1/dids` registry (foreign ids are indistinguishable from
        unknown → `400 invalid`). Duplicate `name` → `409 conflict`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CampaignWrite" }
            example:
              name: postman-golden-1751600000
              default_timezone: America/Chicago
              calling_window_start: "00:00:00"
              calling_window_end: "23:59:59"
              abandonment_threshold: 0.03
              caller_ids: ["+13125550199"]
      responses:
        "201":
          description: Created (state `draft`).
          content:
            application/json:
              schema:
                type: object
                required: [campaign]
                properties:
                  campaign: { $ref: "#/components/schemas/Campaign" }
              example:
                campaign:
                  id: 3f2c1b0a-9d8e-4c7b-a6f5-4e3d2c1b0a99
                  tenant_id: 0d4f4f9e-1f2a-4b53-9d3c-8a5e2f7b1c10
                  name: postman-golden-1751600000
                  state: draft
                  campaign_type: predictive
                  channel: voice
                  sms_body: null
                  abandonment_threshold: "0.03"
                  min_dial_ratio: "1.0"
                  max_dial_ratio: "3.0"
                  calling_window_start: "00:00:00"
                  calling_window_end: "23:59:59"
                  default_timezone: America/Chicago
                  record_calls: true
                  ai_voice: false
                  carrier: null
                  caller_ids: ["+13125550199"]
                  caller_id_pool: []
                  created_at: "2026-07-05T10:00:00Z"
                  updated_at: "2026-07-05T10:00:00Z"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "409": { $ref: "#/components/responses/Conflict" }
  /v1/campaigns/{id}:
    get:
      operationId: getCampaign
      tags: [campaigns]
      summary: Fetch a campaign
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: The campaign.
          content:
            application/json:
              schema:
                type: object
                required: [campaign]
                properties:
                  campaign: { $ref: "#/components/schemas/Campaign" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      operationId: updateCampaign
      tags: [campaigns]
      summary: Update campaign config (never state)
      description: |
        Accepts the same fields as create, and ONLY those: any other key —
        `state` included — is refused with `400 bad_request` naming it, so a
        typo can never come back `200` with the field ignored. Lifecycle is
        explicit via the `start`/`pause`/`stop`/`archive` endpoints.
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CampaignWrite" }
            example:
              record_calls: false
      responses:
        "200":
          description: Updated campaign.
          content:
            application/json:
              schema:
                type: object
                required: [campaign]
                properties:
                  campaign: { $ref: "#/components/schemas/Campaign" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
  /v1/campaigns/{id}/start:
    post:
      operationId: startCampaign
      tags: [campaigns]
      summary: Start a campaign
      description: |
        Persists `running` FIRST, then spawns the runner for the campaign's channel
        (voice → dial loop, `sms_blast` → SMS send loop; same lifecycle API, distinct
        registry key spaces). Optional runner opts are validated fail-closed (`400`
        on bad types). Idempotent if already running. Invalid transition → `409`.
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CampaignStartOpts" }
            example:
              tick_ms: 1000
              batch_size: 2
              gateway: default
      responses:
        "200":
          description: The campaign, now `running`.
          content:
            application/json:
              schema:
                type: object
                required: [campaign]
                properties:
                  campaign: { $ref: "#/components/schemas/Campaign" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
  /v1/campaigns/{id}/resume:
    post:
      operationId: resumeCampaign
      tags: [campaigns]
      summary: Resume a paused campaign (alias of start)
      description: Resume IS start (`paused` → `running`) — one semantics, two spellings (docs parity).
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CampaignStartOpts" }
      responses:
        "200":
          description: The campaign, now `running`.
          content:
            application/json:
              schema:
                type: object
                required: [campaign]
                properties:
                  campaign: { $ref: "#/components/schemas/Campaign" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
  /v1/campaigns/{id}/pause:
    post:
      operationId: pauseCampaign
      tags: [campaigns]
      summary: Pause a running campaign
      description: Persists `paused` and stops the runner; in-flight calls finish via the CDR pipeline.
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: The campaign, now `paused`.
          content:
            application/json:
              schema:
                type: object
                required: [campaign]
                properties:
                  campaign: { $ref: "#/components/schemas/Campaign" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
  /v1/campaigns/{id}/stop:
    post:
      operationId: stopCampaign
      tags: [campaigns]
      summary: Stop a campaign (terminal `completed`)
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: The campaign, now `completed`.
          content:
            application/json:
              schema:
                type: object
                required: [campaign]
                properties:
                  campaign: { $ref: "#/components/schemas/Campaign" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
  /v1/campaigns/{id}/archive:
    post:
      operationId: archiveCampaign
      tags: [campaigns]
      summary: Archive a campaign
      description: "`draft|completed` → `archived` (terminal). Anything else → `409`."
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: The campaign, now `archived`.
          content:
            application/json:
              schema:
                type: object
                required: [campaign]
                properties:
                  campaign: { $ref: "#/components/schemas/Campaign" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
  /v1/campaigns/{id}/dial:
    post:
      operationId: manualDial
      tags: [campaigns]
      summary: Human-initiated dial (preview/manual campaigns)
      description: |
        An agent places ONE call (no auto-pacing) to a debt's primary contact,
        through the SAME gated Originator as campaigns — all compliance gates and
        the spend cap apply. A compliance block is a `200` with `status: "blocked"`
        (a valid outcome the agent must see, not a server error). Only
        `preview`/`manual` campaign types accept it (`422` otherwise).
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [debt_id, agent_id]
              properties:
                debt_id: { type: string, format: uuid }
                agent_id: { type: string, format: uuid }
            example:
              debt_id: 5a4b3c2d-1e0f-4a9b-8c7d-6e5f4a3b2c1d
              agent_id: 1b2c3d4e-5f6a-4b7c-8d9e-0f1a2b3c4d5e
      responses:
        "200":
          description: Dial outcome — dialing, or blocked by a compliance gate.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ManualDialResult" }
              examples:
                dialing:
                  value: { status: dialing, call_id: 6f0a1b2c-3d4e-5f6a-7b8c-9d0e1f2a3b4c }
                blocked:
                  value: { status: blocked, reason: quiet_hours }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "429": { $ref: "#/components/responses/Throttled" }
        "502": { $ref: "#/components/responses/DialError" }
  /v1/campaigns/{id}/next-lead:
    get:
      operationId: nextLead
      tags: [campaigns]
      summary: Preview the next dialable lead (without dialing)
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: The next dialable lead, or `null` when the feed is empty.
          content:
            application/json:
              schema:
                type: object
                required: [lead]
                properties:
                  lead:
                    oneOf:
                      - $ref: "#/components/schemas/PreviewLead"
                      - type: "null"
              example:
                lead:
                  debt_id: 5a4b3c2d-1e0f-4a9b-8c7d-6e5f4a3b2c1d
                  debt_ref: cms-debt-0001
                  account_number: "ACC-1001"
                  debt_type: other
                  debt_state: open
                  contact_phone: "+13125551001"
                  line_type: mobile
                  consumer_state: IL
                  consumer_timezone: America/Chicago
                  last_attempt_at: null
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
  /v1/campaigns/{id}/compliance-preflight:
    get:
      operationId: campaignCompliancePreflight
      tags: [campaigns]
      summary: Compliance preflight (dry-run) of a campaign's dialable leads
      description: |-
        Runs the compliance engine in DRY-RUN over the leads the auto-pacer could offer for this
        campaign (one ranked contact per debt, dialable state, retry watermark elapsed, quarantine
        applied, agentless deliveries excluded) and returns ONE aggregate keyed by consumer state:
        which state rule each lead falls under, how many the gates would allow and how many they
        would block, by blocking rule and reason. Marks nothing and returns no individual lead. Same
        engine, same Request and same durable stores as dialing, so the counters are what launching
        the campaign NOW would produce.

        Honest about the configurable state gate: with `state_matrix_unknown_state_fails_strict` OFF
        for the tenant (the catalog default) a lead with no usable `us_state` keeps only the federal
        7-in-7 — `unknown_state_policy` and `tenant_gates.state_matrix` say `federal_fallback`, never
        "protected". ON, they say `strict` / `active` and the bucket carries the gate's own rule id.

        Cost is bounded: at most 10000 leads are evaluated per call; `totals.leads` is the exact
        population count and `truncated: true` says the evaluation covered only the first
        `totals.evaluated` of it.
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: Preflight aggregate
          content:
            application/json:
              schema:
                type: object
                required: [campaign_id, unknown_state_policy, tenant_gates, totals, truncated, states]
                properties:
                  campaign_id: { type: string, format: uuid }
                  unknown_state_policy:
                    type: string
                    enum: [strict, federal_fallback]
                    description: Posture of the state matrix for a `consumer_state` outside its canonical shape, per the tenant flag.
                  tenant_gates:
                    type: object
                    description: Posture of each gate for this tenant. Only `state_matrix` has an off switch today.
                    properties:
                      dnc: { type: string, enum: [active] }
                      consent: { type: string, enum: [active] }
                      quiet_hours: { type: string, enum: [active] }
                      reg_f: { type: string, enum: [active] }
                      state_matrix: { type: string, enum: [active, federal_fallback] }
                  totals:
                    type: object
                    properties:
                      leads: { type: integer, description: Exact size of the dialable population. }
                      evaluated: { type: integer, description: Leads the engine was run over (≤ leads). }
                      eligible: { type: integer }
                      suppressed: { type: integer }
                      unknown_state: { type: integer, description: Evaluated leads with an empty `us_state`. }
                  truncated:
                    type: boolean
                    description: true when `evaluated < leads` (evaluation bound hit).
                  states:
                    type: object
                    description: Keyed by the RAW `us_state` the engine sees (`"unknown"` for the empty string).
                    additionalProperties:
                      type: object
                      properties:
                        leads: { type: integer }
                        eligible: { type: integer }
                        suppressed: { type: integer }
                        rules:
                          type: object
                          description: Leads per state-matrix rule they fall under (`state.ma.2in7.v1`, `state.wa.3in7.v1`, `city.nyc.2in7.v1`, `state.unknown.strict.v1`, `federal`, `federal_fallback`).
                          additionalProperties: { type: integer }
                        blocks:
                          type: object
                          description: Blocked leads per blocking gate rule id, then per reason code.
                          additionalProperties:
                            type: object
                            additionalProperties: { type: integer }
              example:
                campaign_id: "0190b2a4-7c3e-7f21-9d2a-3f1c2b9e8a10"
                unknown_state_policy: federal_fallback
                tenant_gates: { dnc: active, consent: active, quiet_hours: active, reg_f: active, state_matrix: federal_fallback }
                totals: { leads: 4, evaluated: 4, eligible: 3, suppressed: 1, unknown_state: 1 }
                truncated: false
                states:
                  MA: { leads: 1, eligible: 0, suppressed: 1, rules: { state.ma.2in7.v1: 1 }, blocks: { state.ma.2in7.v1: { state_limit: 1 } } }
                  WA: { leads: 1, eligible: 1, suppressed: 0, rules: { state.wa.3in7.v1: 1 }, blocks: {} }
                  NY: { leads: 1, eligible: 1, suppressed: 0, rules: { city.nyc.2in7.v1: 1 }, blocks: {} }
                  unknown: { leads: 1, eligible: 1, suppressed: 0, rules: { federal_fallback: 1 }, blocks: {} }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/campaigns/{id}/stats:
    get:
      operationId: campaignStats
      tags: [campaigns]
      summary: Live runner + pacing counters
      description: |
        `running: false, stats: null` when no runner is up. Voice runners report
        `{dialed, blocked, finished, reaped, throttled, in_flight, pacing}`; SMS runners report
        `{sent, duplicate, blocked, failed, pending_cooldown}`.
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: Campaign state + live stats.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CampaignStatsResponse" }
              example:
                campaign_id: 3f2c1b0a-9d8e-4c7b-a6f5-4e3d2c1b0a99
                state: running
                running: true
                stats:
                  dialed: 42
                  blocked: 5
                  finished: 30
                  reaped: 0
                  in_flight: 7
                  pacing:
                    ratio: 1.6
                    abandoned: 1
                    connected: 22
                    abandonment_rate: 0.0435
                    tokens: 3
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
  /v1/campaigns/{id}/survey-results:
    get:
      operationId: campaignSurveyResults
      tags: [campaigns]
      summary: Survey / IVR keypress distribution
      description: |
        The read side of the agentless survey responses: how many consumers
        pressed each DTMF key on this campaign over `[from, to]` (both
        inclusive; default trailing 30 UTC days), optionally narrowed to one
        `question_id` (`q1` is the default question the call FSM records).
        `distribution` is sorted by digit; `total` is the sum. Read-only and
        tenant-scoped: a campaign of another tenant is `404`, never an empty
        `200`. `?format=csv` (or `Accept: text/csv`) returns the same numbers
        as a CSV artifact with its own `X-Artifact-SHA256` — same path as
        `/v1/stats/ai-usage` and `/v1/reports/violations-prevented`.
      parameters:
        - $ref: "#/components/parameters/PathId"
        - $ref: "#/components/parameters/FromFilter"
        - $ref: "#/components/parameters/ToFilter"
        - name: question_id
          in: query
          description: Narrow to one question (non-empty string). Omitted = every question.
          schema: { type: string, minLength: 1 }
        - $ref: "#/components/parameters/CsvFormat"
      responses:
        "200":
          description: Digit distribution (JSON) or the CSV artifact.
          headers:
            X-Artifact-SHA256:
              description: CSV responses only — sha256 of the artifact bytes.
              schema: { type: string }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SurveyResults" }
              example:
                campaign_id: 3f2c1b0a-9d8e-4c7b-a6f5-4e3d2c1b0a99
                from: "2026-06-01T00:00:00Z"
                to: "2026-06-30T23:59:59Z"
                question_id: null
                total: 4
                distribution:
                  - { digit: "1", count: 2 }
                  - { digit: "2", count: 1 }
                  - { digit: "9", count: 1 }
            text/csv:
              schema: { type: string }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
  /v1/campaigns/{id}/rewind/preview:
    get:
      operationId: rewindPreviewQuery
      tags: [campaigns]
      summary: Rewind dry-run (filters in query)
      description: |
        Dashboard-friendly GET twin of the POST preview. `excluded_by_compliance`
        counts matching debts the engine would block RIGHT NOW (informational; the
        gates re-enforce at every originate anyway).
      parameters:
        - $ref: "#/components/parameters/PathId"
        - name: dispositions
          in: query
          description: Comma-separated list matched against each debt's LATEST disposition.
          schema: { type: string }
          example: no_answer,busy
        - name: max_attempts
          in: query
          schema: { type: integer }
        - name: last_attempt_older_than_days
          in: query
          schema: { type: integer }
      responses:
        "200":
          description: Dry-run result.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RewindPreviewResult" }
              example: { matching: 12, excluded_by_compliance: 3, debt_ids: [5a4b3c2d-1e0f-4a9b-8c7d-6e5f4a3b2c1d] }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/Unprocessable" }
    post:
      operationId: rewindPreview
      tags: [campaigns]
      summary: Rewind dry-run (filters in body)
      description: Same dry-run as the GET, filters in the JSON body (B1 handoff shape).
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RewindFilters" }
            example:
              max_attempts: 1
      responses:
        "200":
          description: Dry-run result.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RewindPreviewResult" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/Unprocessable" }
  /v1/campaigns/{id}/rewind:
    post:
      operationId: rewindCampaign
      tags: [campaigns]
      summary: Re-queue matching exhausted debts
      description: |
        Sets the per-debt `requeued_at` watermark (the lead feed treats them as
        never-attempted) and resurrects `closed` → `open`; `paid`/`settled`/`disputed`
        are NEVER rewound and the CDR is untouched (append-only; Reg F counters keep
        counting). `keep_scheduled_callbacks: false` cancels the campaign's pending
        callbacks for the rewound debts. `amd_verdicts` is RESERVED → `422`.
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RewindFilters" }
            example:
              max_attempts: 1
              keep_scheduled_callbacks: true
      responses:
        "200":
          description: Rewind applied.
          content:
            application/json:
              schema:
                type: object
                required: [requeued, callbacks_cancelled]
                properties:
                  requeued: { type: integer }
                  callbacks_cancelled: { type: integer }
              example: { requeued: 9, callbacks_cancelled: 0 }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/Unprocessable" }
  # ── debts / leads ───────────────────────────────────────────────────────
  /v1/suppressions/import:
    post:
      operationId: importSuppressions
      tags: [compliance]
      summary: Upload the tenant's own do-not-call list (CSV)
      description: |
        Loads the tenant's INTERNAL suppression list from a `text/csv` body.

        Rows join the `internal_tenant` DNC list that the PRE-DIAL compliance
        gate already consults on every call and every SMS — the same list SMS
        `STOP` writes. There is no second suppression path and nothing to
        enable: an accepted number is suppressed for the next attempt.

        Format: one row per number, `number[,reason]`. A header row
        (`number`, `phone`, `phone_number`, `telephone`, `e164`, `msisdn`) is
        optional; blank lines and lines starting with `#` are skipped. Numbers
        are normalised the same way the regulatory dumps are: E.164 is kept, a
        bare 10-digit or `1`+10-digit NANP number becomes `+1XXXXXXXXXX`.

        Malformed rows are REPORTED, not fatal: refusing a whole file over one
        typo would leave the remaining consumers dialable, which is the
        expensive direction of the error. Rejected values come back MASKED —
        the line number is what identifies the row to fix.

        Re-uploading a list is idempotent (`recorded` counts rows actually
        written, so an overlap reads as an overlap and not as new suppressions).

        Limits: 1 000 000 bytes of body, 10 000 data rows, 200 characters of
        `reason`, and at most 100 entries in `rejected` (`rejected_count`
        always carries the true total).
      requestBody:
        required: true
        content:
          text/csv:
            schema:
              type: string
            example: |
              number,reason
              +13125550100,client internal DNC
              3125550101,litigation hold
              # a comment line is skipped
      responses:
        "200":
          description: |
            Upload outcome. A 200 does NOT mean every row was accepted —
            `rejected_count` is authoritative.
          content:
            application/json:
              schema:
                type: object
                required: [received, accepted, recorded, rejected_count, rejected]
                properties:
                  received:
                    type: integer
                    description: Data rows the document offered (header/blank lines excluded).
                  accepted:
                    type: integer
                    description: Rows that normalised to a distinct E.164 number.
                  recorded:
                    type: integer
                    description: |
                      Rows actually written. Lower than `accepted` when the list
                      overlaps suppressions already on file.
                  rejected_count: { type: integer }
                  rejected:
                    type: array
                    description: Up to 100 entries; see `rejected_count` for the total.
                    items:
                      type: object
                      required: [line, value, reason]
                      properties:
                        line:
                          type: integer
                          description: 1-based line number in the uploaded document.
                        value:
                          type: string
                          description: The offending value, masked to its last 4 characters.
                        reason:
                          type: string
                          enum: [not_e164, duplicate]
              example:
                received: 3
                accepted: 2
                recorded: 2
                rejected_count: 1
                rejected:
                  - line: 4
                    value: "***5551"
                    reason: not_e164
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "413":
          description: The CSV body exceeds 1 000 000 bytes.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
              example:
                error: { code: payload_too_large, message: csv body exceeds 1000000 bytes }
        "415":
          description: The request body is not `text/csv`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
              example:
                error:
                  code: unsupported_media_type
                  message: this endpoint takes a text/csv body
        "422": { $ref: "#/components/responses/Unprocessable" }
  /v1/debts:
    get:
      operationId: listDebts
      tags: [debts]
      summary: Account list + search (filters, keyset pagination)
      description: |
        One page of the tenant's accounts, read from the wide `debts_wide` view.
        Tenant-scoped: a debt of another tenant is never returned by any filter or
        search term — an id, a `consumer_ref` or a phone that belongs elsewhere
        yields an EMPTY page, never a 403 (existence is not confirmed).

        **Phone numbers come back MASKED** (`primary_phone_masked`, `***` + last
        four). A list is a sweep surface; the full E.164 of ONE account stays on
        `GET /v1/debts/{id}`. `account_number` is not part of this projection at
        all.

        `phone` searches by deterministic blind index and therefore requires a
        normalized E.164 (`+13125550100`); anything else is a 400, not an empty
        page. `consumer_ref` and `external_ref` match EXACTLY.

        `dialable_by` selects the accounts dialable at that instant: a debt the
        disposition engine has not parked (`next_dialable_at` null) counts as
        dialable now and is included.
      parameters:
        - $ref: "#/components/parameters/CampaignIdFilter"
        - name: state
          in: query
          description: Exact debt state. An unknown value is a 400, not an empty page.
          schema: { type: string, enum: [open, in_collection, promise_to_pay, paid, settled, disputed, closed] }
        - name: min_amount_cents
          in: query
          description: Lower bound (inclusive) on the balance, in cents.
          schema: { type: integer, minimum: 0 }
        - name: max_amount_cents
          in: query
          description: Upper bound (inclusive) on the balance, in cents.
          schema: { type: integer, minimum: 0 }
        - name: dialable_by
          in: query
          description: RFC-3339 instant; returns accounts dialable at or before it.
          schema: { type: string, format: date-time }
        - name: phone
          in: query
          description: |
            Normalized E.164 of any contact of the debt. Percent-encode the
            leading `+` as `%2B`: it is a reserved query-string character that
            decodes to a SPACE, so an unescaped number arrives mangled and the
            request is refused with a 400 rather than silently searching for
            something else.
          schema: { type: string, example: "+13125550100" }
        - name: consumer_ref
          in: query
          description: Exact CMS consumer id.
          schema: { type: string, maxLength: 128 }
        - name: external_ref
          in: query
          description: Exact CMS debt id.
          schema: { type: string, maxLength: 128 }
        - name: limit
          in: query
          description: Page size (default 50, max 200).
          schema: { type: integer, default: 50, maximum: 200, minimum: 1 }
        - $ref: "#/components/parameters/CursorParam"
      responses:
        "200":
          description: One page of accounts.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DebtPage" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
  /v1/debts/import:
    post:
      operationId: importDebts
      tags: [debts]
      summary: Bulk JSON lead import (idempotent)
      description: |
        Per-item fail-soft, max 10 000 items per request and max 100 contacts per
        debt. Debts upsert on `external_ref` (THE Reg F counter key), contacts on
        `phone_e164`. Replaying the same payload is safe.

        An unrecognized field in an item — or in one of its contacts — fails THAT
        item and nothing else: it comes back in `failed` with the item's own index
        and a message naming the key, while every other item still imports. A bad
        contact is located by its position inside the item (`contact 0: ...`).
        Fail-soft is why this route answers per item instead of rejecting the whole
        body with a `400` the way the single-object routes do.

        A contact sent with `is_primary: true` asserts which number is THE primary
        for that debt: the previous primary is demoted in the same transaction and
        kept (numbers are never deleted), and the new one is inserted or promoted.
        Omitting `is_primary`, or sending `false`, never changes the existing
        primary. An item carrying more than one `is_primary: true` fails THAT item
        and writes nothing — the correction is ambiguous and last-one-wins would
        lose it silently.

        The import is ADDITIVE on a contact's data fields, and they are TRI-STATE.
        Omitting a field and sending it as `""` (or as blanks) mean the same thing —
        "the feed did not say" — and the stored value is left exactly as it was; a
        re-import can therefore CORRECT a value but never CLEAR one. Sending the
        field as `null` is not a clear either: it FAILS that item, with a message
        naming the field, and nothing is written for it. There is no verb on this
        route that empties a stored field, and that is deliberate: `""` is what an
        exporter puts in every blank cell of a bulk feed, so reading it as "erase"
        would let a routine export silently drop the state and ZIP that decide the
        NYC per-debt cap. `line_type: "unknown"` is NOT an empty value — it is a
        published member of the enum, so the feed IS reclassifying the number and
        it lands.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [debts]
              properties:
                debts:
                  type: array
                  maxItems: 10000
                  items: { $ref: "#/components/schemas/DebtImportItem" }
            example:
              debts:
                - external_ref: cms-debt-0001
                  consumer_ref: cms-consumer-9001
                  debt_type: other
                  account_number: ACC-1001
                  amount_cents: 125000
                  currency: USD
                  campaign_id: 3f2c1b0a-9d8e-4c7b-a6f5-4e3d2c1b0a99
                  contacts:
                    - phone_e164: "+13125551001"
                      line_type: mobile
                      timezone: America/Chicago
                      us_state: IL
                      city: Chicago
                      is_primary: true
      responses:
        "200":
          description: Import outcome (per-item fail-soft).
          content:
            application/json:
              schema:
                type: object
                required: [imported, failed]
                properties:
                  imported: { type: integer }
                  failed:
                    type: array
                    items:
                      type: object
                      properties:
                        index: { type: integer }
                        error: { type: string }
              example: { imported: 1, failed: [] }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
  /v1/debts/{id}:
    get:
      operationId: getDebt
      tags: [debts]
      summary: Debt + contacts
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: The debt with its contacts.
          content:
            application/json:
              schema:
                type: object
                required: [debt]
                properties:
                  debt: { $ref: "#/components/schemas/Debt" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
  /v1/debts/{id}/call_attempts:
    get:
      operationId: listDebtCallAttempts
      tags: [debts]
      summary: Durable per-debt CDR history
      description: |
        Full durable history of the debt (originals + compensating corrections),
        keyset-paginated. Accepts the same filters as `GET /v1/call_attempts`; the
        path `debt_id` always wins over any `?debt_id=` in the query.
      parameters:
        - $ref: "#/components/parameters/PathId"
        - $ref: "#/components/parameters/CampaignIdFilter"
        - $ref: "#/components/parameters/AgentIdFilter"
        - $ref: "#/components/parameters/DispositionFilter"
        - $ref: "#/components/parameters/FromFilter"
        - $ref: "#/components/parameters/ToFilter"
        - $ref: "#/components/parameters/LimitParam"
        - $ref: "#/components/parameters/CursorParam"
      responses:
        "200":
          description: One page of CDR rows.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CallAttemptPage" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
  /v1/debts/{id}/defense-packet:
    get:
      operationId: getDefensePacket
      tags: [debts]
      summary: Litigation Defense Packet (sealed evidence bundle)
      description: |
        ONE sealed JSON evidence bundle per debt, assembled in a single tenant
        transaction: full immutable CDR history with frozen compliance snapshots,
        gate refusals, consent/DNC/C&D state, promises, callbacks, supervision audit
        and the policy versions in force. `integrity.digest` is a SHA-256 over the
        canonical JSON WITHOUT the `integrity` key (object keys sorted bytewise,
        no insignificant whitespace — any third party can re-verify; recipe in
        API.md). With `?format=zip` (or `Accept: application/zip`) ships a ZIP of
        `packet.json` + `summary.txt` with `Content-Disposition: attachment` and
        `X-Artifact-SHA256` over the ZIP bytes.
      parameters:
        - $ref: "#/components/parameters/PathId"
        - name: format
          in: query
          description: "`zip` for the downloadable artifact (equivalent: `Accept: application/zip`)."
          schema: { type: string, enum: [zip] }
      responses:
        "200":
          description: The sealed evidence bundle (JSON envelope or ZIP artifact).
          headers:
            X-Artifact-SHA256:
              description: ZIP responses only — sha256 of the artifact bytes as served.
              schema: { type: string }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DefensePacket" }
              example:
                packet: litigation_defense
                packet_version: 1
                tenant_id: 0d4f4f9e-1f2a-4b53-9d3c-8a5e2f7b1c10
                packet_generated_at: "2026-07-05T10:00:00Z"
                debt: { id: 5a4b3c2d-1e0f-4a9b-8c7d-6e5f4a3b2c1d, external_ref: cms-debt-0001 }
                contacts: []
                call_attempts: []
                conversations: []
                gate_blocks: []
                consents: []
                dnc: { tenant_listings: [], global_listings: [], reassigned_numbers: [] }
                cease_and_desist: []
                promises: []
                callbacks: []
                supervision_actions: []
                policy_versions: [usa-cell.v1]
                integrity:
                  algorithm: sha256
                  digest: "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
                  canonicalization: "sorted-keys utf-8, no insignificant whitespace"
                  worm_attestation: null
            application/zip:
              schema:
                type: string
                format: binary
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
  # ── CDR query + typed dispositions ──────────────────────────────────────
  /v1/call_attempts:
    get:
      operationId: listCallAttempts
      tags: [call-attempts]
      summary: Filtered, keyset-paginated CDR query
      description: |
        Rows are append-only CDR truth: originals have `corrects_id: null`;
        corrections point at the original — the LATEST correction is the final
        disposition. `compliance_snapshot` carries the frozen decision evidence.
      parameters:
        - $ref: "#/components/parameters/CampaignIdFilter"
        - $ref: "#/components/parameters/DebtIdFilter"
        - $ref: "#/components/parameters/AgentIdFilter"
        - $ref: "#/components/parameters/DispositionFilter"
        - $ref: "#/components/parameters/FromFilter"
        - $ref: "#/components/parameters/ToFilter"
        - $ref: "#/components/parameters/LimitParam"
        - $ref: "#/components/parameters/CursorParam"
      responses:
        "200":
          description: One page of CDR rows.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CallAttemptPage" }
              example:
                call_attempts:
                  - id: 8c7d6e5f-4a3b-2c1d-0e9f-8a7b6c5d4e3f
                    tenant_id: 0d4f4f9e-1f2a-4b53-9d3c-8a5e2f7b1c10
                    debt_id: 5a4b3c2d-1e0f-4a9b-8c7d-6e5f4a3b2c1d
                    campaign_id: 3f2c1b0a-9d8e-4c7b-a6f5-4e3d2c1b0a99
                    agent_id: null
                    consumer_ref: cms-consumer-9001
                    debt_key: "debt:cms-debt-0001"
                    call_uuid: 6f0a1b2c-3d4e-5f6a-7b8c-9d0e1f2a3b4c
                    from_number: "+13125550199"
                    to_number: "+13125551001"
                    started_at: "2026-07-05T15:00:00Z"
                    answered_at: null
                    ended_at: null
                    disposition: null
                    hangup_cause: null
                    sip_response_code: null
                    compliance_snapshot: { decision_id: 2c1d0e9f-8a7b-6c5d-4e3f-2a1b0c9d8e7f, policy_version: usa-cell.v1 }
                    amd_verdict: null
                    corrects_id: null
                    inserted_at: "2026-07-05T15:00:00Z"
                next_cursor: null
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
  /v1/call_attempts/{id}/disposition:
    post:
      operationId: recordDisposition
      tags: [call-attempts]
      summary: Typed agent/AI outcome (+promise for PTP)
      description: |
        Records an outcome for a finished attempt as a compensating CDR record (the
        table stays append-only). `{id}` must be an ORIGINAL row (`corrects_id:
        null`); corrections and unknown ids are `404`. Typed contract is
        bidirectional: `promise_to_pay` REQUIRES the `promise` payload; a `promise`
        payload on any other disposition → `422`. Correction + promise commit in ONE
        transaction; a PTP also advances the debt's lead state to `promise_to_pay`.

        An unrecognized field INSIDE `promise` is a `400` naming the key, and
        nothing is appended to the CDR. Scope, stated because the difference is
        easy to misread: only `promise` is strict. Unknown fields at the TOP level
        of this body are still ignored, since the top level is read key by key
        rather than scrubbed against a whitelist.

        `note` is the agent's free-text note about the CALL and is accepted on
        EVERY disposition, not only `promise_to_pay`. Do not confuse it with
        `promise.note`, which describes the TERMS of the agreement and lands on
        the promise record; both may travel in the same request and are stored
        separately. The note comes back on `call_attempt.note` here and on every
        other call-attempt surface, so the next agent to work the account reads it
        in the debt's history.
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [disposition]
              properties:
                disposition:
                  type: string
                  description: Non-empty typed outcome, e.g. `promise_to_pay`, `wrong_number`.
                agent_id: { type: string, format: uuid }
                note:
                  oneOf: [{ type: string }, { type: "null" }]
                  description: >-
                    Free-text agent note about the call, accepted on ANY disposition.
                    Encrypted at rest (per-tenant envelope) and never queried by
                    content. Omit it (or send `null`) for no note; `""` is stored as
                    no note.
                promise:
                  type: object
                  description: REQUIRED iff `disposition` is `promise_to_pay`.
                  # The server refuses a key outside this list with a 400 naming
                  # it, so the schema can say so instead of leaving generated
                  # clients to guess that extras are tolerated.
                  additionalProperties: false
                  required: [amount_cents, currency, promised_date]
                  properties:
                    amount_cents: { type: integer, exclusiveMinimum: 0 }
                    currency: { type: string, enum: [MXN, USD] }
                    promised_date:
                      type: string
                      format: date
                      description: Future date, max 90 days out.
                    note: { type: string }
            examples:
              simple:
                value: { disposition: wrong_number }
              with_note:
                value:
                  disposition: answered_third_party
                  note: his wife answered, he gets home at 7pm
              promise_to_pay:
                value:
                  disposition: promise_to_pay
                  agent_id: 1b2c3d4e-5f6a-4b7c-8d9e-0f1a2b3c4d5e
                  promise:
                    amount_cents: 12500
                    currency: USD
                    promised_date: "2026-07-21"
                    note: two installments
      responses:
        "201":
          description: The compensating CDR record (+ the promise for PTP).
          content:
            application/json:
              schema:
                type: object
                required: [call_attempt, promise]
                properties:
                  call_attempt: { $ref: "#/components/schemas/CallAttempt" }
                  promise:
                    oneOf:
                      - $ref: "#/components/schemas/Promise"
                      - type: "null"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "429": { $ref: "#/components/responses/DispositionThrottled" }
  # ── callbacks ───────────────────────────────────────────────────────────
  /v1/callbacks:
    post:
      operationId: scheduleCallback
      tags: [callbacks]
      summary: Schedule a consumer-agreed redial
      description: |
        `contact_phone` must already be a contact of the debt (the gates need
        line/locale data) → `422` otherwise; less than 10 minutes notice → `422`;
        the number is in R16 quarantine → `422` (the platform will not promise a
        call it cannot make); unknown debt → `404`; bad timestamp → `400`.
        `campaign_id` defaults to the debt's. The campaign runner dials due
        callbacks BEFORE fresh leads.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [debt_id, contact_phone, scheduled_at]
              properties:
                debt_id: { type: string, format: uuid }
                contact_phone: { type: string, description: E.164 of an existing contact of the debt. }
                scheduled_at: { type: string, format: date-time, description: "RFC 3339, at least 10 minutes in the future." }
                campaign_id: { type: string, format: uuid }
                agent_id: { type: string, format: uuid }
                note: { type: string }
                priority: { type: integer }
              additionalProperties: false
            example:
              debt_id: 5a4b3c2d-1e0f-4a9b-8c7d-6e5f4a3b2c1d
              contact_phone: "+13125551001"
              scheduled_at: "2026-07-06T16:30:00Z"
              note: "postman: consumer pidió el martes"
      responses:
        "201":
          description: Scheduled callback.
          content:
            application/json:
              schema:
                type: object
                required: [callback]
                properties:
                  callback: { $ref: "#/components/schemas/Callback" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/Unprocessable" }
    get:
      operationId: listCallbacks
      tags: [callbacks]
      summary: Callback queue view / due feed
      description: |
        `due=now` (also `true`/`1`) is the runner-facing due feed (pending AND
        `scheduled_at <= now`, priority then time order); otherwise the queue view
        filtered by `status` (default `pending`, soonest first).
      parameters:
        - name: due
          in: query
          schema: { type: string, enum: [now, "true", "1"] }
        - name: status
          in: query
          schema: { type: string, enum: [pending, done, cancelled, missed], default: pending }
        - $ref: "#/components/parameters/CampaignIdFilter"
        - $ref: "#/components/parameters/DebtIdFilter"
        - $ref: "#/components/parameters/LimitParam"
      responses:
        "200":
          description: Callbacks matching the view.
          content:
            application/json:
              schema:
                type: object
                required: [callbacks]
                properties:
                  callbacks:
                    type: array
                    items: { $ref: "#/components/schemas/Callback" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
  /v1/callbacks/{id}/complete:
    post:
      operationId: completeCallback
      tags: [callbacks]
      summary: pending → done
      description: Status machine `pending → done|cancelled|missed` (terminal); `409` on re-transition.
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: The completed callback.
          content:
            application/json:
              schema:
                type: object
                required: [callback]
                properties:
                  callback: { $ref: "#/components/schemas/Callback" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
  /v1/callbacks/{id}/cancel:
    post:
      operationId: cancelCallback
      tags: [callbacks]
      summary: pending → cancelled
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: The cancelled callback.
          content:
            application/json:
              schema:
                type: object
                required: [callback]
                properties:
                  callback: { $ref: "#/components/schemas/Callback" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
  /v1/callbacks/{id}/miss:
    post:
      operationId: missCallback
      tags: [callbacks]
      summary: pending → missed
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: The missed callback.
          content:
            application/json:
              schema:
                type: object
                required: [callback]
                properties:
                  callback: { $ref: "#/components/schemas/Callback" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
  # ── promises ────────────────────────────────────────────────────────────
  /v1/promises:
    get:
      operationId: listPromises
      tags: [promises]
      summary: List promises (PTP pipeline)
      parameters:
        - $ref: "#/components/parameters/DebtIdFilter"
        - name: status
          in: query
          schema: { type: string, enum: [pending, kept, broken, cancelled] }
        - $ref: "#/components/parameters/LimitParam"
      responses:
        "200":
          description: Promises, newest first.
          content:
            application/json:
              schema:
                type: object
                required: [promises]
                properties:
                  promises:
                    type: array
                    items: { $ref: "#/components/schemas/Promise" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
  /v1/promises/{id}:
    patch:
      operationId: updatePromise
      tags: [promises]
      summary: Resolve a promise
      description: |
        Status machine is `pending → kept|broken|cancelled` (terminal): re-transitions
        → `409 conflict`; other strings → `400`. Rows are never deleted.
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [status]
              properties:
                status: { type: string, enum: [kept, broken, cancelled] }
            example:
              status: kept
      responses:
        "200":
          description: The resolved promise.
          content:
            application/json:
              schema:
                type: object
                required: [promise]
                properties:
                  promise: { $ref: "#/components/schemas/Promise" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
  # ── agents (seats) ──────────────────────────────────────────────────────
  /v1/agents:
    get:
      operationId: listAgents
      tags: [agents]
      summary: Roster + live seat presence
      description: Durable roster merged with live presence (`offline|available|ringing|on_call`).
      responses:
        "200":
          description: All agents with presence.
          content:
            application/json:
              schema:
                type: object
                required: [agents]
                properties:
                  agents:
                    type: array
                    items: { $ref: "#/components/schemas/Agent" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      operationId: createAgent
      tags: [agents]
      summary: Create a seat
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, email]
              properties:
                name: { type: string }
                email: { type: string, format: email }
                role: { type: string, enum: [agent, supervisor, admin] }
                sip_extension:
                  type: string
                  pattern: '^[0-9A-Za-z._-]+$'
                  description: >-
                    The SIP registration identity calls bridge to. Only inert
                    characters (digits, letters, dot, underscore, hyphen): the
                    value is both the SIP identity and half of the media
                    dialstring, so anything else is refused with 422 rather
                    than diverging between the two.
              additionalProperties: false
            example:
              name: Postman Seat
              email: postman+1751600000@example.test
              role: agent
              sip_extension: "1009"
      responses:
        "201":
          description: Created seat.
          content:
            application/json:
              schema:
                type: object
                required: [agent]
                properties:
                  agent: { $ref: "#/components/schemas/Agent" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "409": { $ref: "#/components/responses/Conflict" }
  /v1/agents/{id}:
    get:
      operationId: getAgent
      tags: [agents]
      summary: Fetch one agent + presence
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: The agent.
          content:
            application/json:
              schema:
                type: object
                required: [agent]
                properties:
                  agent: { $ref: "#/components/schemas/Agent" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
  /v1/agents/{id}/credential:
    post:
      operationId: setAgentCredential
      tags: [agents]
      summary: Enable or rotate an agent's widget-login password
      description: |
        Sets (or rotates) the agent's widget-login credential so they can sign
        into the widget with email + password (`POST /v1/auth/agent-login`).
        Tenant scope (a `dd_` key or `ddu_` session; a `ddw_` is `403`). The
        plaintext password is body-only — never persisted (PBKDF2-SHA512 at
        rest) or echoed back. A ROTATION revokes the agent's live `ddw_` (the
        re-key eviction) AND tears down the SIP endpoint minted under that
        session (NR-02), reported in `sip_teardown`.
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [password]
              properties:
                password:
                  type: string
                  format: password
                  minLength: 12
                  maxLength: 128
      responses:
        "200":
          description: "The agent (now login_enabled true), plus the SIP teardown outcome."
          content:
            application/json:
              schema:
                type: object
                required: [agent, sip_teardown]
                properties:
                  agent: { $ref: "#/components/schemas/Agent" }
                  sip_teardown: { $ref: "#/components/schemas/SipTeardown" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      operationId: disableAgentCredential
      tags: [agents]
      summary: Disable an agent's widget login
      description: |
        Clears the agent's widget-login password (back to `login_enabled:
        false`) and revokes any live `ddw_` the agent holds, so the disable
        takes effect at once — INCLUDING the SIP endpoint (NR-02), reported in
        `sip_teardown`. Without that half, a disabled agent kept dialling over
        SIP until the credential's 24h autoexpire. The seat itself is untouched.
        Idempotent. Tenant scope (a `ddw_` is `403`).
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: "The agent (now login_enabled false), plus the SIP teardown outcome."
          content:
            application/json:
              schema:
                type: object
                required: [agent, sip_teardown]
                properties:
                  agent: { $ref: "#/components/schemas/Agent" }
                  sip_teardown: { $ref: "#/components/schemas/SipTeardown" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /v1/agents/checkin:
    post:
      operationId: agentCheckin
      tags: [agents]
      summary: Seat online + softphone bootstrap
      description: |
        Seat goes `available`. Optional `device_mode` (`browser`|`external`) persists
        on the seat and refines the campaign bridge hunt; omitted keeps the stored
        value (re-checkin stays idempotent). When SBC provisioning is enabled
        (`KAMAILIO_RPC_URL`; always on in prod) the response carries the FULL
        softphone bootstrap — an ephemeral SIP password minted per checkin,
        provisioned into Kamailio's `agentcred` htable (the ONLY place it exists;
        24h autoexpire). Re-checkin reissues. With provisioning disabled the `sip`
        key is simply absent.

        Two failures AFTER the seat is opened answer `503` and revert it, so the
        seat is never left claiming an agent the caller cannot reach. Branch on
        `code`: `sbc_unavailable` when the SBC RPC fails — credentials that
        cannot register are never returned — and `presence_unavailable` when the
        durable seat-presence write fails (core#557).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [sip_extension]
              properties:
                sip_extension: { type: string }
                device_mode:
                  type: string
                  enum: [browser, external]
                  description: browser = kamailio-first WSS softphone; external = straight to the FS registrar.
            example:
              sip_extension: "1001"
              device_mode: external
      responses:
        "200":
          description: Seat online (+ softphone bootstrap when SBC provisioning is on).
          content:
            application/json:
              schema:
                type: object
                required: [agent]
                properties:
                  agent: { $ref: "#/components/schemas/Agent" }
                  sip: { $ref: "#/components/schemas/SipBootstrap" }
              example:
                agent:
                  id: 1b2c3d4e-5f6a-4b7c-8d9e-0f1a2b3c4d5e
                  tenant_id: 0d4f4f9e-1f2a-4b53-9d3c-8a5e2f7b1c10
                  name: Postman Seat
                  email: postman@example.test
                  role: agent
                  status: active
                  sip_extension: "1001"
                  device_mode: external
                  presence: available
                  created_at: "2026-06-01T12:00:00Z"
                  updated_at: "2026-07-05T10:00:00Z"
                sip:
                  extension: "1001"
                  password: ephemeral-example-not-real
                  wss_url: wss://127.0.0.1:8443
                  domain: sip.usa.dialerdigital.com
                  expires_at: "2026-07-06T10:00:00Z"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "503": { $ref: "#/components/responses/CheckinUnavailable" }
  /v1/agents/checkout:
    post:
      operationId: agentCheckout
      tags: [agents]
      summary: Seat offline (revokes the ephemeral SIP credential)
      description: |
        Only an `available` seat may leave (`409` otherwise, fail-closed — a seat on
        a call cannot vanish). Revokes the ephemeral SIP credential (idempotent
        htable delete; the 24h autoexpire is the backstop for a missed revoke).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [sip_extension]
              properties:
                sip_extension: { type: string }
            example:
              sip_extension: "1001"
      responses:
        "200":
          description: Seat offline.
          content:
            application/json:
              schema:
                type: object
                required: [agent]
                properties:
                  agent: { $ref: "#/components/schemas/Agent" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
  # ── pause / resume (MT-ROAD-R11 FASE 2a, macro-tasks#524) ───────────────
  /v1/agents/{id}/pause:
    post:
      operationId: agentPause
      tags: [agents]
      summary: Take an available seat out of rotation (paused)
      description: |
        Moves an `available` (or `wrap_up`) seat to `paused` with a reason from a
        CLOSED set; a paused seat is never handed a call and may still check out.
        Tenant scope only (a `ddw_` agent token is 403 by the widget allowlist).
        `409` when the agent is not checked in, is reserved / on a call, or is
        already paused (fail-closed: nothing is coerced). The seat is in-memory
        state; whether the seat stays out of rotation across a restart depends
        on the tenant's `agent_session_journal_enabled` flag (ADR #101 D10).
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [reason]
              properties:
                reason:
                  type: string
                  enum: [manual, break, lunch, training, meeting]
            example:
              reason: "lunch"
      responses:
        "200":
          description: Seat paused; `agent.presence` is read back from the seat.
          content:
            application/json:
              schema:
                type: object
                required: [agent]
                properties:
                  agent: { $ref: "#/components/schemas/Agent" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
  /v1/agents/{id}/resume:
    post:
      operationId: agentResume
      tags: [agents]
      summary: Return a paused seat to rotation (available)
      description: |
        Moves a `paused` seat back to `available`, at the BACK of the reservation
        queue. Tenant scope only. `409` when the agent is not checked in or the
        seat is not paused.
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: Seat available again; `agent.presence` is read back from the seat.
          content:
            application/json:
              schema:
                type: object
                required: [agent]
                properties:
                  agent: { $ref: "#/components/schemas/Agent" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
  /v1/agents/{id}/wrap-up:
    post:
      operationId: agentCompleteWrapUp
      tags: [agents]
      summary: Finish after-call work (wrap_up -> available)
      description: |
        The agent is done with after-call work: moves a `wrap_up` seat back to
        `available`, at the BACK of the reservation queue, and drops the reservation
        token the call left on the seat. Tenant scope only. `409` when the agent is
        not checked in or the seat is not in `wrap_up`.

        There is NO verb that ENTERS `wrap_up`: the seat FSM enters it only on the
        hangup of an ANSWERED call, and only when the tenant's `agent_wrap_up_seconds`
        cap is above 0 (ADR #101 D5/D8). The cap is the other exit — the seat returns
        to `available` on its own when it expires — and `POST /v1/agents/{id}/pause`
        is the third (an agent may close its ACW with a pause).
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: After-call work finished; `agent.presence` is read back from the seat.
          content:
            application/json:
              schema:
                type: object
                required: [agent]
                properties:
                  agent: { $ref: "#/components/schemas/Agent" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
  # ── planned shifts (MT-ROAD-R11 FASE 2b) ───────────────────────────────
  /v1/planned-shifts:
    get:
      operationId: listPlannedShifts
      summary: List planned shifts for the tenant
      tags:
        - agents
      security:
        - tenantApiKey: []
      responses:
        '200':
          description: List of planned shifts
          content:
            application/json:
              schema:
                type: object
                required:
                  - shifts
                properties:
                  shifts:
                    type: array
                    items:
                      $ref: '#/components/schemas/PlannedShift'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
    post:
      operationId: createPlannedShift
      summary: Create a planned shift
      tags:
        - agents
      security:
        - tenantApiKey: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - agent_id
                - start_time
                - end_time
              properties:
                agent_id:
                  type: string
                  format: uuid
                start_time:
                  type: string
                  format: date-time
                end_time:
                  type: string
                  format: date-time
                planned_state:
                  type: string
                  enum: [available, ringing, on_call, wrap_up, paused]
                  default: available
      responses:
        '201':
          description: Planned shift created.
          content:
            application/json:
              schema:
                type: object
                required: [id]
                properties:
                  id:
                    type: string
                    format: uuid
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  # ── supervision (supervisor audio) ──────────────────────────────────────
  /v1/supervision/calls/{call_id}/listen:
    post:
      operationId: superviseListen
      tags: [supervision]
      summary: Silent monitor (audible to nobody)
      description: |
        Originates a supervision leg to the supervisor's REGISTERED endpoint running
        FreeSWITCH `eavesdrop` on the call. EVIDENCE FIRST: every attempt — success
        or failure, including probes at unknown uuids — writes a durable
        `supervision_actions` row BEFORE any switch command (failures flip it to
        `failed`, refusals to `refused`, never delete it). `{call_id}` is the live
        call's FreeSWITCH uuid (= `call_attempts.call_uuid`). Cross-tenant uuids are
        indistinguishable from nonexistent and trigger ZERO switch commands. One
        active mode per supervisor per call: a new mode hangs up the previous leg.

        WHO may supervise (ADR #116): `supervisor_ext` must resolve to an active
        agent whose `role` is `supervisor` or `admin`; a plain `agent` is `403`
        `role_forbidden` (evidenced as `refused`, ZERO switch commands), for every
        principal (`dd_`, `ddu_`). The role check decides before the call lookup's
        outcome is acted on (an unknown call is `403`, not `404`, for an unauthorized
        caller: no 404-vs-403 oracle).
        Governed per tenant by the catalog key `supervision_role_enforced`
        (default ON; OFF restores "any active extension"). An agent (`ddw_`)
        principal is stopped by the default-deny widget allowlist before this
        operation; behind it, it may name only its own extension (`403 forbidden`).
      parameters:
        - $ref: "#/components/parameters/CallId"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SupervisionRequest" }
            example:
              supervisor_ext: "2000"
      responses:
        "200":
          description: Supervision leg originated (audit row durable).
          content:
            application/json:
              schema:
                type: object
                required: [supervision]
                properties:
                  supervision: { $ref: "#/components/schemas/SupervisionResult" }
              example:
                supervision:
                  action: listen
                  call_id: 6f0a1b2c-3d4e-5f6a-7b8c-9d0e1f2a3b4c
                  supervisor_ext: "2000"
                  session_id: 7a8b9c0d-1e2f-3a4b-5c6d-7e8f9a0b1c2d
                  audit_id: 9d0e1f2a-3b4c-5d6e-7f8a-9b0c1d2e3f4a
                  to_number: "***1001"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PlanInactive" }
        "403": { $ref: "#/components/responses/SupervisionRoleForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/SupervisionQuotaFull" }
        "502": { $ref: "#/components/responses/SupervisionFailed" }
        "503": { $ref: "#/components/responses/SupervisionUnavailable" }
  /v1/supervision/calls/{call_id}/whisper:
    post:
      operationId: superviseWhisper
      tags: [supervision]
      summary: Whisper (audible to the agent leg only)
      description: Same contract and evidence-first semantics as `listen`; the coaching audio reaches only the agent.
      parameters:
        - $ref: "#/components/parameters/CallId"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SupervisionRequest" }
      responses:
        "200":
          description: Supervision leg originated (audit row durable).
          content:
            application/json:
              schema:
                type: object
                required: [supervision]
                properties:
                  supervision: { $ref: "#/components/schemas/SupervisionResult" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PlanInactive" }
        "403": { $ref: "#/components/responses/SupervisionRoleForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/SupervisionQuotaFull" }
        "502": { $ref: "#/components/responses/SupervisionFailed" }
        "503": { $ref: "#/components/responses/SupervisionUnavailable" }
  /v1/supervision/calls/{call_id}/barge:
    post:
      operationId: superviseBarge
      tags: [supervision]
      summary: Barge (three-way, audible to both sides)
      description: Same contract and evidence-first semantics as `listen`; the supervisor is audible to agent AND debtor.
      parameters:
        - $ref: "#/components/parameters/CallId"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SupervisionRequest" }
      responses:
        "200":
          description: Supervision leg originated (audit row durable).
          content:
            application/json:
              schema:
                type: object
                required: [supervision]
                properties:
                  supervision: { $ref: "#/components/schemas/SupervisionResult" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PlanInactive" }
        "403": { $ref: "#/components/responses/SupervisionRoleForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/SupervisionQuotaFull" }
        "502": { $ref: "#/components/responses/SupervisionFailed" }
        "503": { $ref: "#/components/responses/SupervisionUnavailable" }
  /v1/supervision/calls/{call_id}/takeover:
    post:
      operationId: superviseTakeover
      tags: [supervision]
      summary: Takeover (debtor transferred to the supervisor)
      description: |
        Transfers the debtor leg to the supervisor and hangs up the orphaned agent
        leg. `session_id` is `null` — there is no eavesdrop leg. Same evidence-first
        audit contract as the other modes.
      parameters:
        - $ref: "#/components/parameters/CallId"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SupervisionRequest" }
      responses:
        "200":
          description: Debtor leg transferred (audit row durable).
          content:
            application/json:
              schema:
                type: object
                required: [supervision]
                properties:
                  supervision: { $ref: "#/components/schemas/SupervisionResult" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/SupervisionRoleForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "502": { $ref: "#/components/responses/SupervisionFailed" }
        "503": { $ref: "#/components/responses/SupervisionUnavailable" }
  # ── call control (CTI verbs) ─────────────────────────────────────────────
  /v1/calls:
    get:
      operationId: listCalls
      tags: [calls]
      summary: Call state for the CTI surface (keyset-paginated, `?live=true` for in-flight)
      description: |
        The read half of the CTI surface (`WidgetAllowlist` pre-authorizes it
        for `ddw_`): the same append-only CDR rows as `/v1/call_attempts`,
        keyed by `call_uuid` for the control verbs, with `?live=true`
        restricting to calls still in flight (`ended_at` null, originals only).

        SCOPE: a tenant (`dd_`/`ddu_`) principal reads the whole floor and may
        filter by any `agent_id`; an agent (`ddw_`) principal reads ONLY its
        own calls — the `agent_id` filter is FORCED to the token's bound agent,
        and naming any other agent is `403`, never silently rewritten.
      security:
        - tenantApiKey: []
        - userSessionToken: []
        - widgetSessionToken: []
      parameters:
        - $ref: "#/components/parameters/CampaignIdFilter"
        - $ref: "#/components/parameters/DebtIdFilter"
        - $ref: "#/components/parameters/AgentIdFilter"
        - $ref: "#/components/parameters/DispositionFilter"
        - name: live
          in: query
          required: false
          schema: { type: boolean }
          description: "`true` → only calls still in flight (`ended_at` null; originals only). Strict boolean: anything else is `400`."
        - $ref: "#/components/parameters/FromFilter"
        - $ref: "#/components/parameters/ToFilter"
        - $ref: "#/components/parameters/LimitParam"
        - $ref: "#/components/parameters/CursorParam"
      responses:
        "200":
          description: One page of calls.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CallPage" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
  /v1/calls/{call_id}:
    get:
      operationId: getCall
      tags: [calls]
      summary: One call by its call_uuid
      description: |
        The ORIGINAL CDR row of the call (`corrects_id` null — the row the
        control verbs key on). Ownership mirrors the verbs: a `ddw_` principal
        may read ONLY a call it owns (`403` otherwise — the verbs already
        disclose ownership, so a read gains nothing by masking it as `404`);
        a foreign or unknown `call_id` is `404`.
      security:
        - tenantApiKey: []
        - userSessionToken: []
        - widgetSessionToken: []
      parameters:
        - $ref: "#/components/parameters/CallId"
      responses:
        "200":
          description: The call.
          content:
            application/json:
              schema:
                type: object
                required: [call]
                properties:
                  call: { $ref: "#/components/schemas/CallAttempt" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /v1/calls/{call_id}/hold:
    post:
      operationId: callHold
      tags: [calls]
      summary: Place a live call on hold
      description: |
        Holds the AGENT leg so the consumer hears hold-music (evidence-first:
        a `call_control_actions` row is written before the ESL command). `409`
        when the call has no bridged agent leg.
      security:
        - tenantApiKey: []
        - userSessionToken: []
        - widgetSessionToken: []
      parameters:
        - $ref: "#/components/parameters/CallId"
      responses:
        "200":
          description: Held (audit row durable).
          content:
            application/json:
              schema:
                type: object
                required: [call]
                properties:
                  call: { $ref: "#/components/schemas/CallControlResult" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/CallControlThrottled" }
        "502": { $ref: "#/components/responses/CallControlFailed" }
        "503": { $ref: "#/components/responses/CallControlUnavailable" }
  /v1/calls/{call_id}/unhold:
    post:
      operationId: callUnhold
      tags: [calls]
      summary: Release a held call (re-checks GATE_MID_CALL)
      description: |
        Releases the hold. Re-evaluates the mid-call trio — consent revocation,
        written cease-and-desist (live reads) and quiet hours against the
        consumer timezone(s) frozen in the call's compliance snapshot. Blocked
        -> `422`: the call stays on hold and an automatic hangup is scheduled
        (`auto_hangup_at` in the body, C9) so the consumer is never kept held
        indefinitely. `409` when the call has no bridged agent leg.
      security:
        - tenantApiKey: []
        - userSessionToken: []
        - widgetSessionToken: []
      parameters:
        - $ref: "#/components/parameters/CallId"
      responses:
        "200":
          description: Released (audit row durable).
          content:
            application/json:
              schema:
                type: object
                required: [call]
                properties:
                  call: { $ref: "#/components/schemas/CallControlResult" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/MidCallComplianceBlocked" }
        "429": { $ref: "#/components/responses/CallControlThrottled" }
        "502": { $ref: "#/components/responses/CallControlFailed" }
        "503": { $ref: "#/components/responses/CallControlUnavailable" }
  /v1/calls/{call_id}/mute:
    post:
      operationId: callMute
      tags: [calls]
      summary: Mute the agent microphone on a live call
      description: |
        Mutes the audio the agent reads into the bridge (their microphone),
        evidence-first. `409` when the call has no bridged agent leg.
      security:
        - tenantApiKey: []
        - userSessionToken: []
        - widgetSessionToken: []
      parameters:
        - $ref: "#/components/parameters/CallId"
      responses:
        "200":
          description: Muted (audit row durable).
          content:
            application/json:
              schema:
                type: object
                required: [call]
                properties:
                  call: { $ref: "#/components/schemas/CallControlResult" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/CallControlThrottled" }
        "502": { $ref: "#/components/responses/CallControlFailed" }
        "503": { $ref: "#/components/responses/CallControlUnavailable" }
  /v1/calls/{call_id}/unmute:
    post:
      operationId: callUnmute
      tags: [calls]
      summary: Unmute the agent microphone on a live call
      description: Restores the agent's microphone audio, evidence-first.
      security:
        - tenantApiKey: []
        - userSessionToken: []
        - widgetSessionToken: []
      parameters:
        - $ref: "#/components/parameters/CallId"
      responses:
        "200":
          description: Unmuted (audit row durable).
          content:
            application/json:
              schema:
                type: object
                required: [call]
                properties:
                  call: { $ref: "#/components/schemas/CallControlResult" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/CallControlThrottled" }
        "502": { $ref: "#/components/responses/CallControlFailed" }
        "503": { $ref: "#/components/responses/CallControlUnavailable" }
  /v1/calls/{call_id}/hangup:
    post:
      operationId: callHangup
      tags: [calls]
      summary: Hang up a live call
      description: |
        Tears the call down (kills the consumer leg), evidence-first. The CDR
        pipeline emits `call.ended` when the leg dies.
      security:
        - tenantApiKey: []
        - userSessionToken: []
        - widgetSessionToken: []
      parameters:
        - $ref: "#/components/parameters/CallId"
      responses:
        "200":
          description: Hangup issued (audit row durable).
          content:
            application/json:
              schema:
                type: object
                required: [call]
                properties:
                  call: { $ref: "#/components/schemas/CallControlResult" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "502": { $ref: "#/components/responses/CallControlFailed" }
        "503": { $ref: "#/components/responses/CallControlUnavailable" }
  # ── stats ───────────────────────────────────────────────────────────────
  /v1/stats/summary:
    get:
      operationId: statsSummary
      tags: [stats]
      summary: Today's operational counters (UTC day)
      description: Computed from the append-only CDR + live seat registry — reconstructible truth, not cached state.
      responses:
        "200":
          description: Today's counters.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/StatsSummary" }
              example:
                date: "2026-07-05"
                window: utc_day
                calls_today: 412
                connected_today: 67
                connect_rate: 0.1626
                agents_online: 5
                agents_available: 3
                campaigns_running: 2
        "401": { $ref: "#/components/responses/Unauthorized" }
  /v1/stats/promises:
    get:
      operationId: statsPromises
      tags: [stats]
      summary: PTP pipeline summary
      description: Currencies never sum together; `kept_rate_30d` is `null` when nothing resolved in the window.
      responses:
        "200":
          description: Pipeline summary.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PromisesPipelineSummary" }
              example:
                open: { count: 4, amount_cents: { USD: 50000, MXN: 120000 } }
                kept_rate_30d: 0.75
                window_days: 30
        "401": { $ref: "#/components/responses/Unauthorized" }
  /v1/stats/penetration:
    get:
      operationId: statsPenetration
      tags: [stats]
      summary: Person-level penetration analytics
      description: |
        Person-level dedup (each debt counts once): `attempted` = at least one
        original attempt, `reached` = any non-failed disposition or conversation,
        `effective_contact` = `answered_human` or conversation. `campaign_ids` is
        required (max 100; `campaign_id=` also accepted).
      parameters:
        - name: campaign_ids
          in: query
          required: true
          description: Comma-separated campaign UUIDs (max 100).
          schema: { type: string }
          example: 3f2c1b0a-9d8e-4c7b-a6f5-4e3d2c1b0a99
        - $ref: "#/components/parameters/FromFilter"
        - $ref: "#/components/parameters/ToFilter"
      responses:
        "200":
          description: Penetration totals + per-campaign rows.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PenetrationReport" }
              example:
                totals:
                  campaign_id: null
                  unique_accounts: 4
                  unique_consumers: 4
                  attempted: 2
                  reached: 1
                  effective_contact: 1
                  attempted_pct: 50.0
                  reached_pct: 25.0
                  effective_contact_pct: 25.0
                by_campaign:
                  - campaign_id: 3f2c1b0a-9d8e-4c7b-a6f5-4e3d2c1b0a99
                    unique_accounts: 4
                    unique_consumers: 4
                    attempted: 2
                    reached: 1
                    effective_contact: 1
                    attempted_pct: 50.0
                    reached_pct: 25.0
                    effective_contact_pct: 25.0
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
  /v1/stats/blocked:
    get:
      operationId: statsBlocked
      tags: [stats]
      summary: Durable blocked-dial rollup
      description: |
        Reads the durable, APPEND-ONLY `gate_blocks` evidence: one row per (debt,
        gate, campaign, UTC day) the compliance engine REFUSED to dial. Every number
        is a count of distinct refusals, never an estimate. Defaults: trailing 30
        UTC days.
      parameters:
        - $ref: "#/components/parameters/FromFilter"
        - $ref: "#/components/parameters/ToFilter"
        - $ref: "#/components/parameters/CampaignIdFilter"
      responses:
        "200":
          description: Blocked-dial rollup.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BlockedStats" }
              example:
                from: "2026-06-05T00:00:00Z"
                to: "2026-07-05T00:00:00Z"
                campaign_id: null
                total: 5
                by_gate: { quiet_hours: 4, dnc_listed: 1 }
                daily:
                  - date: "2026-06-01"
                    total: 3
                    by_gate: { quiet_hours: 2, dnc_listed: 1 }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
  /v1/stats/capacity:
    get:
      operationId: statsCapacity
      tags: [stats]
      summary: Tenant capacity ceilings + live usage (ADR #12)
      description: |
        The tenant's admin-set capacity ceilings (`max_cps`,
        `max_concurrent_channels`; `null` = no ceiling) plus live usage read
        from the capacity gate. `limits: null` = the ceilings could not be read
        AND were never cached — the exact condition the gate fails CLOSED on;
        surfaced honestly, never fabricated as "unlimited".

        `supervision` is a SECOND, independent ceiling (ADR #37): live-call
        supervision legs originate outside the dial choke point, so they are
        counted in their own quota. Neither lane can exhaust the other — the
        dialing floor can be at its ceiling while supervision still admits, and
        vice versa.
      responses:
        "200":
          description: Capacity ceilings + live usage.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CapacityStats" }
              example:
                limits: { max_cps: 10, max_concurrent_channels: 50 }
                channels_in_use: 3
                dials_last_second: 2
                supervision: { limit: 20, in_use: 1 }
        "401": { $ref: "#/components/responses/Unauthorized" }
  /v1/stats/ai-usage:
    get:
      operationId: statsAiUsage
      tags: [stats]
      summary: Voice-AI minute metering (invoicing read)
      description: |
        Off the durable exactly-once `ai_usage` feed (one row per answered AI call,
        billed EXACTLY ONCE on `call_uuid`). Each row snapshots the per-minute rate
        it was metered under — changing the configured rate never reprices history.
        The window is half-open — `from` inclusive, `to` EXCLUSIVE — so a call
        answered exactly at a billing cut belongs to one period only (the one
        starting at that instant), never to both sides of the cut.
        `?format=csv` (or `Accept: text/csv`) returns the billing artifact with its
        own `X-Artifact-SHA256`.
      parameters:
        - $ref: "#/components/parameters/FromFilter"
        - $ref: "#/components/parameters/ToFilter"
        - $ref: "#/components/parameters/CampaignIdFilter"
        - $ref: "#/components/parameters/CsvFormat"
      responses:
        "200":
          description: Metering stats (JSON) or the CSV billing artifact.
          headers:
            X-Artifact-SHA256:
              description: CSV responses only — sha256 of the artifact bytes.
              schema: { type: string }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AiUsageStats" }
              example:
                from: "2026-06-05T00:00:00Z"
                to: "2026-07-05T00:00:00Z"
                campaign_id: null
                minute_rate_usd: 0.25
                calls: 3
                billable_seconds: 270
                billable_minutes: 4.5
                estimated_cost_usd: 1.13
                daily:
                  - date: "2026-06-01"
                    calls: 2
                    billable_seconds: 180
                    billable_minutes: 3.0
                    minute_rate_usd: 0.25
                    estimated_cost_usd: 0.75
            text/csv:
              schema: { type: string }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
  /v1/stats/sms-usage:
    get:
      operationId: statsSmsUsage
      tags: [stats]
      summary: SMS segment metering (invoicing read)
      description: |
        Straight off the unified ledger: `sms_segment` billable events, exactly-once
        at provider ACCEPT, each priced at its region-rate snapshot. JSON, or
        `?format=csv` / `Accept: text/csv` for the billing artifact.
      parameters:
        - $ref: "#/components/parameters/FromFilter"
        - $ref: "#/components/parameters/ToFilter"
        - $ref: "#/components/parameters/CampaignIdFilter"
        - $ref: "#/components/parameters/CsvFormat"
      responses:
        "200":
          description: Metering stats (JSON) or the CSV billing artifact.
          headers:
            X-Artifact-SHA256:
              description: CSV responses only — sha256 of the artifact bytes.
              schema: { type: string }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SmsUsageStats" }
              example:
                from: "2026-06-05T00:00:00Z"
                to: "2026-07-05T00:00:00Z"
                campaign_id: null
                segment_rates_usd: { us: "0.0100", mx: "0.0300", eu: "0.0500" }
                messages: 2
                segments: "3"
                amount_usd: "0.03"
                daily:
                  - date: "2026-07-01"
                    messages: 2
                    segments: "3"
                    segment_rate_usd: "0.0100"
                    amount_usd: "0.03"
            text/csv:
              schema: { type: string }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
  # ── billing / reports ───────────────────────────────────────────────────
  /v1/billing/account:
    get:
      operationId: billingAccount
      tags: [billing]
      summary: Balance, the cap in force, spend against it and the enforcement mode
      description: |
        The "am I about to stop dialling" read, and the counterpart of an
        enforcement that is unconditional since ADR #39: a tenant that runs out
        of balance stops dialling, and this is the tenant-facing surface that
        can say so first.

        Every number here is a READ of something that already exists, never a
        second source. Money comes from the wallet the spend guard nets its
        holds into — open holds count as spent, so `available_usd` is
        conservative-early. `cap` comes from the same arithmetic the spend-alert
        writer fires from, and `used_pct` is floored, so this field never
        announces a threshold that has not actually been crossed.

        `enforcement_mode` and `alert_thresholds` are read-only here. They are
        written from the admin runbook surface
        (`PUT /v1/admin/tenants/{id}/plan/enforcement-mode` and
        `.../plan/alerts`): the customer tunes WHEN it is warned, never WHETHER
        it is blocked.

        A tenant with billing off answers `200` with `billing_enabled: false`
        and null money — not `404`, which would make "billing is not on for you"
        indistinguishable from "your token points at nothing". `cap` is
        additionally null whenever the lane is inert: plan not effectively
        active, no wallet, no funding entry, or (subscription) no grant yet.
      responses:
        "200":
          description: The account read. `balance` and `cap` are independently nullable.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BillingAccount" }
              example:
                tenant_id: 0d4f4f9e-1f2a-4b53-9d3c-8a5e2f7b1c10
                currency: USD
                billing_enabled: true
                scheme: prepaid
                plan_status: active
                enforcement_mode: graduated
                alert_thresholds: [80, 90, 100]
                balance:
                  cash_usd: "412.5000"
                  grant_usd: "0.0000"
                  deposit_usd: "0.0000"
                  credit_limit_usd: "0.0000"
                  available_usd: "387.5000"
                cap:
                  scheme: prepaid
                  period_key: wallet:9f1c2f7e-77aa-4a1e-9a2b-6c0d5e4f3a21
                  limit_usd: "1000.0000"
                  spent_usd: "612.5000"
                  used_pct: "61.2"
        "401": { $ref: "#/components/responses/Unauthorized" }
  /v1/billing/alerts:
    get:
      operationId: billingAlerts
      tags: [billing]
      summary: Spend-alert threshold crossings recorded in the current billing epoch
      description: |
        The history behind the percentage that `GET /v1/billing/account`
        reports live: which thresholds have actually fired for this tenant in
        the epoch the alerts are armed against.

        Every row is served **as it was recorded**, never recomputed.
        `spent_usd`, `limit_usd` and `enforcement_mode` are the snapshot AT the
        crossing, which is the reason the underlying ledger is append-only: an
        80% warning stays an 80% warning even if the tenant topped up
        afterwards. Re-deriving these from today's balance would answer "what
        would this alert look like if it fired now" — a different question, and
        one that would let this list contradict the notification the customer
        already received.

        `period_key` is the SAME epoch `GET /v1/billing/account` reports under
        `cap.period_key`, so the two surfaces can never be describing different
        periods. It changes when the tenant is funded or at a new subscription
        period, which re-arms every threshold with zero deletes — so an empty
        list right after a top-up means "nothing crossed YET in the new epoch",
        not "nothing ever crossed".

        A tenant with no active plan, or whose lane has no epoch yet, answers
        200 with `period_key: null` and an empty list — never 404, for the same
        reason as `/billing/account`: a 404 would make "you have crossed
        nothing" indistinguishable from "your token is pointing at nothing".
      responses:
        "200":
          description: |
            The crossings of the current epoch, newest first. Empty when the
            lane is inert or nothing has crossed yet.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BillingAlerts" }
              example:
                tenant_id: 0d4f4f9e-1f2a-4b53-9d3c-8a5e2f7b1c10
                period_key: wallet:9f1c2f7e-77aa-4a1e-9a2b-6c0d5e4f3a21
                alerts:
                  - threshold_pct: 90
                    scheme: prepaid
                    enforcement_mode: graduated
                    spent_usd: "902.0000"
                    limit_usd: "1000.0000"
                    crossed_at: "2026-08-30T18:22:04.518423Z"
                  - threshold_pct: 80
                    scheme: prepaid
                    enforcement_mode: graduated
                    spent_usd: "812.5000"
                    limit_usd: "1000.0000"
                    crossed_at: "2026-08-29T11:04:51.201884Z"
        "401": { $ref: "#/components/responses/Unauthorized" }
  /v1/billing/summary:
    get:
      operationId: billingSummary
      tags: [billing]
      summary: Invoiceable line items over a caller-chosen window (NOT the invoice)
      description: |
        The exploratory read model: the caller picks `[from, to)`, so this is
        what the line items look like over an arbitrary window — useful to
        inspect, and NOT the bill. The invoice is
        `GET /v1/billing/invoice`, whose period the SERVER sets and the
        client cannot move.

        Seats by tier (3-seat billing floor), DID overage ($25 / 10-DID pack) and
        voice-AI metered usage (`max(metered, $200)` while the add-on is enabled,
        `$0` when off) priced from the plan catalog in code (`Dialer.Billing.Plan`).
        Defaults: trailing 30 UTC days. The `[from, to)` window is half-open (`to`
        EXCLUSIVE); day-ledger lines (house/inbound minutes, DID rent, SMS) derive
        their closed UTC-day range from it, so a `to` at exact UTC midnight is a
        billing cut and that day belongs to the NEXT period's view only.
        `?format=csv` / `Accept: text/csv` for the invoicing artifact (with
        `X-Artifact-SHA256`).
      parameters:
        - $ref: "#/components/parameters/FromFilter"
        - $ref: "#/components/parameters/ToFilter"
        - $ref: "#/components/parameters/CsvFormat"
      responses:
        "200":
          description: The invoiceable line items (JSON) or the CSV artifact.
          headers:
            X-Artifact-SHA256:
              description: CSV responses only — sha256 of the artifact bytes.
              schema: { type: string }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BillingSummary" }
              example:
                tenant_id: 0d4f4f9e-1f2a-4b53-9d3c-8a5e2f7b1c10
                tier: scale
                tier_label: Scale
                voice_ai_enabled: true
                currency: USD
                period: { from: "2026-06-05T00:00:00Z", to: "2026-07-05T00:00:00Z" }
                line_items:
                  - kind: seats
                    cadence: monthly
                    source: live
                    occupied_seats: 5
                    billable_seats: 5
                    seat_minimum: 3
                    unit_price_usd: 119
                    amount_usd: 595
                  - kind: did_overage
                    cadence: monthly
                    source: live
                    monitored_dids: 8
                    included_dids: 3
                    overage_dids: 1
                    pack_size: 10
                    pack_price_usd: 25
                    amount_usd: 25
                  - kind: did_overage_customer
                    cadence: monthly
                    source: live
                    monitored_dids: 8
                    included_dids: 3
                    overage_dids: 4
                    pack_size: 10
                    pack_price_usd: 25
                    amount_usd: 0
                  - kind: wav_surcharge
                    cadence: monthly
                    source: live
                    recording_format: wav
                    billable_seats: 5
                    unit_price_usd: 5
                    amount_usd: 25
                  - kind: voice_ai
                    cadence: metered
                    enabled: true
                    billable_minutes: 900.0
                    minute_rate_usd: 0.25
                    metered_usd: 225.0
                    monthly_minimum_usd: 200
                    minimum_applied: false
                    amount_usd: 225.0
                total_usd: 870.0
            text/csv:
              schema: { type: string }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/Unprocessable" }
  /v1/billing/invoice:
    get:
      operationId: billingInvoice
      tags: [billing]
      summary: The invoice for one calendar month, with the period set by the SERVER
      description: |
        Same line items as `GET /v1/billing/summary`, over a window the CLIENT
        CANNOT CHOOSE: the billing cycle is the calendar month — the same key
        the monthly close stamps its `seat_month` ledger row with — and the
        only knob is which month.

        `period` is `YYYY-MM` and defaults to the month in flight. Anything
        that is not a real month is a 400, never a nearby month.

        `from` and `to` are REJECTED here with a 400 that names them, rather
        than ignored: a caller that sent them believes it narrowed the bill,
        so silently billing a different window would be indistinguishable from
        honouring them. Use `/v1/billing/summary` for a free range.

        Reading the same closed month twice returns the same document — that
        is the point of anchoring the period server-side.

        Once the monthly close has ISSUED the month's document, this read
        serves THAT: the frozen `invoices`/`invoice_lines` rows — numbered,
        immutable, never a recompute — as the `FrozenInvoice` shape
        (`source: frozen_invoice`, with `invoice_number`). A month without
        an issued document (the month in flight, or a past month whose
        close has not converged yet) answers the live `BillingSummary`
        read-model, which has no invoice number.

        `?format=csv` / `Accept: text/csv` for the invoicing artifact (with
        `X-Artifact-SHA256`). The meta block's `report` row discriminates the
        two, exactly like `source` does in the JSON: a frozen month is the
        document's rows verbatim (`report,invoice`, and the meta carries
        `invoice_number` and `issued_at`), while a month with no issued
        document is the read-model (`report,invoice_draft`, no invoice number).
        The filenames follow the same split: `invoice_<period_key>_n<num>.csv`
        once issued, `invoice_<period_key>.csv` while it is not.
      parameters:
        - name: period
          in: query
          required: false
          description: The calendar month to invoice, `YYYY-MM`. Defaults to the current UTC month.
          schema: { type: string, pattern: '^\d{4}-\d{2}$' }
          example: "2026-06"
        - $ref: "#/components/parameters/CsvFormat"
      responses:
        "200":
          description: The invoice line items (JSON) or the CSV artifact.
          headers:
            X-Artifact-SHA256:
              description: CSV responses only — sha256 of the artifact bytes.
              schema: { type: string }
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: "#/components/schemas/FrozenInvoice"
                  - $ref: "#/components/schemas/BillingSummary"
              examples:
                frozen:
                  summary: The issued document — the month is closed and frozen
                  value:
                    source: frozen_invoice
                    tenant_id: 0d4f4f9e-1f2a-4b53-9d3c-8a5e2f7b1c10
                    invoice_number: 7
                    period_key: "2026-06"
                    period_from: "2026-06-01"
                    period_to: "2026-06-30"
                    currency: USD
                    issued_at: "2026-07-01T00:00:07.412331Z"
                    total: "595.0000"
                    line_items:
                      - position: 1
                        line_type: seats
                        description: 5 billable seats (closed-month snapshot) x $119
                        quantity: "5.0000"
                        unit_price: "119.0000"
                        amount: "595.0000"
                live:
                  summary: No issued document yet — the live read-model
                  value:
                    tenant_id: 0d4f4f9e-1f2a-4b53-9d3c-8a5e2f7b1c10
                    tier: scale
                    tier_label: Scale
                    voice_ai_enabled: true
                    currency: USD
                    period: { from: "2026-06-01T00:00:00Z", to: "2026-07-01T00:00:00Z" }
                    line_items:
                      - kind: seats
                        cadence: monthly
                        source: ledger_snapshot
                        occupied_seats: null
                        billable_seats: 5
                        seat_minimum: 3
                        unit_price_usd: 119
                        amount_usd: 595
                    total_usd: 595
            text/csv:
              schema: { type: string }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/Unprocessable" }
  /v1/reports/attempts:
    get:
      operationId: exportAttempts
      tags: [reports]
      summary: Export attempt history (Reg F)
      description: |
        Keyset-paginated CSV export of the attempt ledger for the CMS round-trip
        (MT-REP-05 FASE 2). One row per attempt carrying its FINAL disposition:
        the original dial collapsed with every correction appended to it, the
        most recently written row wins. `call_id` is always the ORIGINAL attempt
        id, so a correction landing between two exports does not re-identify the
        attempt. `source` (`platform` | `cms_import`) is on every row so the CMS
        can tell what it declared from what the platform dialled.

        Columns: `debt_key, attempt_at (UTC RFC 3339), channel (voice), source,
        outcome, agent_id (empty when none), call_id, export_cursor`.

        `from` and `to` are mandatory and MUST be at most 31 days apart (`400`
        otherwise). Derived from the ledger on every call — nothing is
        materialised. Rows are ordered by `attempt_at` descending, then by
        `call_id`; resume with `?after=<export_cursor of the last row>`. A
        malformed cursor is `400`, never a silent restart from the first page.
        `format=csv` (or `Accept: text/csv`) is required: `400` without it.
      parameters:
        - name: from
          in: query
          required: true
          description: RFC 3339 lower bound on `attempt_at` (inclusive).
          schema: { type: string, format: date-time }
        - name: to
          in: query
          required: true
          description: RFC 3339 upper bound on `attempt_at` (inclusive); at most 31 days after `from`.
          schema: { type: string, format: date-time }
        - name: limit
          in: query
          description: Page size (rows per response); values above the maximum are capped.
          schema: { type: integer, minimum: 1, maximum: 1000, default: 1000 }
        - name: after
          in: query
          description: |
            Opaque keyset cursor: the `export_cursor` of the last row of the
            previous page. Malformed ⇒ `400`.
          schema: { type: string }
        - $ref: "#/components/parameters/CsvFormat"
      responses:
        "200":
          description: CSV artifact
          headers:
            Content-Disposition:
              schema: { type: string }
            X-Artifact-SHA256:
              schema: { type: string }
          content:
            text/csv:
              schema: { type: string }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/reports/violations-prevented:
    get:
      operationId: violationsPrevented
      tags: [reports]
      summary: Violations-Prevented compliance artifact
      description: |
        Packaged compliance report over the append-only `gate_blocks` evidence.
        `by_campaign` sorted by blocked count (desc); `samples` are the ≤3 most
        recent evidence rows PER gate, always masked (last-4 only — the DB CHECK
        refuses unmasked phones at write time). `?format=csv` / `Accept: text/csv`
        renders the downloadable CSV (`Content-Disposition: attachment`) with
        `X-Artifact-SHA256` for chain of custody.
      parameters:
        - $ref: "#/components/parameters/FromFilter"
        - $ref: "#/components/parameters/ToFilter"
        - $ref: "#/components/parameters/CsvFormat"
      responses:
        "200":
          description: The report (JSON) or the CSV artifact.
          headers:
            X-Artifact-SHA256:
              description: CSV responses only — sha256 of the artifact bytes.
              schema: { type: string }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ViolationsPreventedReport" }
              example:
                report: violations_prevented
                tenant_id: 0d4f4f9e-1f2a-4b53-9d3c-8a5e2f7b1c10
                generated_at: "2026-07-05T10:00:00Z"
                period: { from: "2026-06-05T00:00:00Z", to: "2026-07-05T00:00:00Z" }
                totals: { total: 5, by_gate: { quiet_hours: 4, dnc_listed: 1 } }
                by_campaign:
                  - campaign_id: 3f2c1b0a-9d8e-4c7b-a6f5-4e3d2c1b0a99
                    campaign_name: postman-golden
                    total: 5
                    by_gate: { quiet_hours: 4, dnc_listed: 1 }
                policy_versions: [usa-cell.v1]
                samples:
                  - occurred_at: "2026-07-01T02:10:00Z"
                    gate: quiet_hours
                    rule_id: usa-cell.quiet-hours
                    campaign_id: 3f2c1b0a-9d8e-4c7b-a6f5-4e3d2c1b0a99
                    debt_id: 5a4b3c2d-1e0f-4a9b-8c7d-6e5f4a3b2c1d
                    contact_phone_masked: "***1001"
                    policy_version: usa-cell.v1
            text/csv:
              schema: { type: string }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
  # ── DID registry + health ───────────────────────────────────────────────
  /v1/dids:
    get:
      operationId: listDids
      tags: [dids]
      summary: DID registry
      parameters:
        - name: status
          in: query
          schema: { type: string, enum: [active, quarantine, retired] }
      responses:
        "200":
          description: Registry rows ordered by e164.
          content:
            application/json:
              schema:
                type: object
                required: [dids]
                properties:
                  dids:
                    type: array
                    items: { $ref: "#/components/schemas/DID" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      operationId: createDid
      tags: [dids]
      summary: Register a tenant-owned number
      description: |
        `e164` unique per tenant → `409 conflict` on duplicates; `npa` is derived
        from `+1` numbers when omitted. The tenant surface can only ever create
        `origin: customer` rows — house inventory is admin-minted
        (`POST /v1/admin/tenants/{id}/dids`).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              # CLOSED (MT-SEC-49 MT.3b): an unrecognized key is `400 bad_request` naming it, never a silent drop.
              additionalProperties: false
              required: [e164]
              properties:
                e164: { type: string, description: E.164 (immutable after creation). }
                npa: { type: string }
                us_state: { type: string }
                attestation: { type: string, enum: [A, B, C, unknown] }
                status: { type: string, enum: [active, quarantine, retired], default: active }
                labels:
                  type: array
                  items: { type: string }
                notes: { type: string }
                origin:
                  type: string
                  enum: [house, customer]
                  description: >-
                    Accepted and IGNORED FOR SAFETY: a tenant can never mint
                    `origin: house`, so any value here is overridden with
                    `customer`. The enum mirrors the response schema because a
                    `GET` of a house DID returns `house` — sending that value
                    back does not fail, it has no effect. Declared so that
                    THIS key does not become a 400 now that unknown keys are.
                    It does NOT make the whole rendered object round-trip:
                    `id`, `tenant_id`, `created_at` and `updated_at` are not
                    accepted and DO get the 400.
            example:
              e164: "+13125550142"
              us_state: IL
      responses:
        "201":
          description: Registered DID.
          content:
            application/json:
              schema:
                type: object
                required: [did]
                properties:
                  did: { $ref: "#/components/schemas/DID" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "409": { $ref: "#/components/responses/Conflict" }
  /v1/dids/{id}:
    get:
      operationId: getDid
      tags: [dids]
      summary: Fetch one DID
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: The DID.
          content:
            application/json:
              schema:
                type: object
                required: [did]
                properties:
                  did: { $ref: "#/components/schemas/DID" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      operationId: updateDid
      tags: [dids]
      summary: Update metadata/status (e164 immutable)
      description: A different number is a different DID; CDR history is never re-attributed.
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              # CLOSED (MT-SEC-49 MT.3b): an unrecognized key is `400 bad_request` naming it, never a silent drop.
              additionalProperties: false
              properties:
                npa: { type: string }
                us_state: { type: string }
                attestation: { type: string, enum: [A, B, C, unknown] }
                status: { type: string, enum: [active, quarantine, retired] }
                labels:
                  type: array
                  items: { type: string }
                notes: { type: string }
                origin:
                  type: string
                  enum: [house, customer]
                  description: >-
                    Accepted and IGNORED FOR SAFETY, same as on
                    `POST /v1/dids`: `origin` is immutable and never cast, so
                    any value here has no effect. Sending back the value a
                    `GET` returned does not fail; the rest of the rendered
                    object still does (`id`, `tenant_id`, `e164`,
                    `created_at`, `updated_at` are a 400).
            example:
              attestation: A
              labels: [postman]
              notes: postman smoke
      responses:
        "200":
          description: Updated DID.
          content:
            application/json:
              schema:
                type: object
                required: [did]
                properties:
                  did: { $ref: "#/components/schemas/DID" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      operationId: retireDid
      tags: [dids]
      summary: Retire a DID (rows never delete)
      description: |
        Sets status `retired`, idempotent. Rows never leave the registry (DELETE is
        revoked in Postgres): CDR `from_number` provenance must keep resolving for
        the Defense Packet.
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: The retired DID.
          content:
            application/json:
              schema:
                type: object
                required: [did]
                properties:
                  did: { $ref: "#/components/schemas/DID" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
  /v1/dids/{id}/health:
    get:
      operationId: didHealth
      tags: [dids]
      summary: CDR-computed DID health
      description: |
        Health is COMPUTED from the append-only CDR at read time — every number is a
        row count, never a cached score. `short_call_rate` (answered calls ended in
        under 15 s / answered) is the strongest CDR-side "spam likely" signal. Rates
        are `null` when the denominator is zero. `reputation.hiya`/`reputation.tns`
        are explicit nulls until an external feed is contracted. A retired DID still
        serves health (provenance outlives rotation).
      parameters:
        - $ref: "#/components/parameters/PathId"
        - $ref: "#/components/parameters/FromFilter"
        - $ref: "#/components/parameters/ToFilter"
      responses:
        "200":
          description: Computed health for the period (default trailing 30 UTC days).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DidHealth" }
              example:
                did_id: 4e3d2c1b-0a9f-4e8d-7c6b-5a4f3e2d1c0b
                e164: "+13125550184"
                status: active
                period: { from: "2026-06-05T00:00:00Z", to: "2026-07-05T00:00:00Z" }
                dials: 412
                answered: 67
                answer_rate: 0.1626
                short_calls: 24
                short_call_rate: 0.3582
                short_call_seconds: 15
                daily:
                  - date: "2026-06-01"
                    dials: 80
                    answered: 14
                    short_calls: 5
                reputation:
                  hiya: null
                  tns: null
                  note: external reputation feeds not integrated yet
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
  # ── carriers (SIP trunks) ───────────────────────────────────────────────
  /v1/carriers:
    get:
      operationId: listCarriers
      tags: [carriers]
      summary: List carriers (trunks)
      responses:
        "200":
          description: Own trunks + inherited empresa-wide trunks.
          content:
            application/json:
              schema:
                type: object
                required: [carriers]
                properties:
                  carriers:
                    type: array
                    items: { $ref: "#/components/schemas/Carrier" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      operationId: createCarrier
      tags: [carriers]
      summary: Register a SIP trunk
      description: |
        `scope: "empresa"` creates a shared trunk (tenant_id null) inherited by
        every sede of the account; `scope: "sede"` (default) creates a per-sede
        trunk. `account_id`/`tenant_id` are NEVER taken from the body — the router
        injects them from the session (a sede cannot forge another account's
        carrier).

        `scope: "empresa"` additionally requires an `account_admin` user (see
        `User.role`) — it overrides the routing of every sibling sede, so a
        `member` or a machine key gets **403**. The RLS policy enforces it, not
        only this route.

        Since MT-SEC-49 MT.3b an unrecognized key is a **400** naming it.
        `account_id` is the one exception and is documented below: it is
        accepted and ignored. `tenant_id` is NOT — sending it is a
        cross-tenant forgery attempt and gets the 400.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              # CLOSED (MT-SEC-49 MT.3b): an unrecognized key is `400 bad_request` naming it, never a silent drop.
              additionalProperties: false
              required: [kind, name]
              properties:
                kind: { type: string, enum: [house, byoc] }
                name: { type: string }
                sip_proxy: { type: string }
                source_ips:
                  type: array
                  items: { type: string }
                status: { type: string, enum: [active, disabled, retired] }
                scope:
                  type: string
                  enum: [sede, empresa]
                  default: sede
                  description: Request-only field; responses derive it from `tenant_id`.
                account_id:
                  type: string
                  format: uuid
                  description: >-
                    Accepted and IGNORED ON PURPOSE: the account always comes
                    from the session, so a forged value has no effect.
                    Declared so that THIS key does not become a 400 now that
                    unknown keys are. It does NOT make the whole rendered
                    trunk round-trip: `id`, `created_at` and `updated_at` are
                    a 400, and `tenant_id` is a 400 DELIBERATELY — sending it
                    is a cross-tenant forgery attempt and gets named, not
                    dropped in silence.
            example:
              kind: byoc
              name: postman-trunk-1751600000
              sip_proxy: sip:pstn.example.test:5060
              source_ips: [203.0.113.9]
      responses:
        "201":
          description: Registered trunk.
          content:
            application/json:
              schema:
                type: object
                required: [carrier]
                properties:
                  carrier: { $ref: "#/components/schemas/Carrier" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
  /v1/carriers/{id}:
    get:
      operationId: getCarrier
      tags: [carriers]
      summary: Fetch one carrier
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: The carrier.
          content:
            application/json:
              schema:
                type: object
                required: [carrier]
                properties:
                  carrier: { $ref: "#/components/schemas/Carrier" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
  /v1/carriers/{id}/provision:
    post:
      operationId: provisionCarrier
      tags: [carriers]
      summary: Push the trunk to the SIP edge (Kamailio)
      description: |
        Only a BYOC carrier can be provisioned (`422` otherwise). With no
        `KAMAILIO_RPC_URL` configured (dev without the telecom harness) the answer
        is `200 {"status": "sbc_disabled"}` — the row's intent is recorded, it just
        is not pushed to a live edge.
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: Provisioned (or SBC disabled in this environment).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SbcStatus" }
              examples:
                provisioned: { value: { status: provisioned } }
                sbc_disabled: { value: { status: sbc_disabled } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "502": { $ref: "#/components/responses/SbcError" }
  /v1/carriers/{id}/revoke:
    post:
      operationId: revokeCarrier
      tags: [carriers]
      summary: Remove the trunk from the SIP edge
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: Revoked (or SBC disabled in this environment).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SbcStatus" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "502": { $ref: "#/components/responses/SbcError" }
  # ── SMS messages ────────────────────────────────────────────────────────
  /v1/sms/messages:
    post:
      operationId: sendSms
      tags: [sms]
      summary: One-off gated SMS send
      description: |
        Routes ONE message through THE choke point (`Dialer.Sms.Sender`, the same
        Originator as campaigns): spend cap → compliance engine (Consent → DNC →
        QuietHours → SmsFrequency, fail-closed) → evidence-first `queued` ledger row
        → provider adapter → `sms_segment` debit at provider ACCEPT. The API can no
        more bypass a gate than a campaign can.

        `client_ref` is the caller idempotency key — retries MUST reuse it; a
        replayed `client_ref` answers `202` with `duplicate: true` and the provider
        is NOT contacted again. When `timezone` is absent quiet-hours resolves the
        destination's NPA — unresolvable numbers are blocked fail-closed. Provider
        resolution walks sede → empresa-wide → deployment config; no provider →
        `422 sms_provider_unconfigured`. A row that resolution selects but whose
        `base_url` no longer answers as an https endpoint on port 443 at a public address →
        `422 sms_provider_base_url_rejected`: the SSRF guard re-resolves the host at
        SEND time (not just at registration), so a name re-pointed at loopback,
        RFC1918, CGNAT or link-local afterwards is caught here and the send fails
        closed instead of falling back to another endpoint.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [to, body]
              properties:
                to: { type: string, description: "Destination, E.164." }
                body: { type: string, description: Message text (include the STOP opt-out notice). }
                from: { type: string, description: Sender E.164 / short code. }
                client_ref: { type: string, description: Idempotency key (default a fresh uuid). }
                debt_id: { type: string, format: uuid, description: "Attribution (with campaign_id, also lands the durable gate_blocks row on a block)." }
                campaign_id: { type: string, format: uuid }
                consumer_ref: { type: string }
                timezone: { type: string, description: IANA timezone for quiet-hours; NPA fallback when absent. }
            example:
              to: "+19995550123"
              body: postman probe. Responda STOP para no recibir mas mensajes.
              client_ref: postman-1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d
      responses:
        "202":
          description: "Accepted (queued → sent, billed at ACCEPT) — or replayed `client_ref` (`duplicate: true`, provider NOT contacted again)."
          content:
            application/json:
              schema:
                type: object
                required: [sms_message, duplicate]
                properties:
                  sms_message:
                    oneOf:
                      - $ref: "#/components/schemas/SmsMessageView"
                      - type: "null"
                  duplicate: { type: boolean }
              example:
                sms_message:
                  id: 0e9f8a7b-6c5d-4e3f-2a1b-0c9d8e7f6a5b
                  direction: outbound
                  status: sent
                  from_e164: "+13125550199"
                  to_e164: "+19995550123"
                  body: postman probe. Responda STOP para no recibir mas mensajes.
                  segments: 1
                  client_ref: postman-1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d
                  provider_id: 6a5b4c3d-2e1f-4a0b-9c8d-7e6f5a4b3c2d
                  provider_message_id: mock-0001
                  campaign_id: null
                  debt_id: null
                  compliance_snapshot: { decision_id: 2c1d0e9f-8a7b-6c5d-4e3f-2a1b0c9d8e7f }
                  occurred_at: "2026-07-05T10:00:00Z"
                  timestamps: { queued: "2026-07-05T10:00:00Z", sent: "2026-07-05T10:00:01Z" }
                  transitions: []
                duplicate: false
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422": { $ref: "#/components/responses/ComplianceBlocked" }
        "429": { $ref: "#/components/responses/SmsRateLimited" }
        "502": { $ref: "#/components/responses/SmsProviderError" }
    get:
      operationId: listSmsMessages
      tags: [sms]
      summary: SMS ledger query
      description: |
        Ledger ROWS — originals AND status transitions (`corrects_id`
        distinguishes) — newest first, keyset-paginated like `/v1/call_attempts`.
        Bodies are 40-char previews (`body_preview`) in lists.
      parameters:
        - name: direction
          in: query
          schema: { type: string, enum: [outbound, inbound] }
        - name: status
          in: query
          schema: { type: string, enum: [queued, sent, delivered, undelivered, failed, received] }
        - $ref: "#/components/parameters/CampaignIdFilter"
        - $ref: "#/components/parameters/DebtIdFilter"
        - $ref: "#/components/parameters/LimitParam"
        - $ref: "#/components/parameters/CursorParam"
      responses:
        "200":
          description: One page of ledger rows.
          content:
            application/json:
              schema:
                type: object
                required: [sms_messages, next_cursor]
                properties:
                  sms_messages:
                    type: array
                    items: { $ref: "#/components/schemas/SmsMessage" }
                  next_cursor:
                    oneOf:
                      - type: string
                      - type: "null"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
  /v1/sms/messages/{id}:
    get:
      operationId: getSmsMessage
      tags: [sms]
      summary: Collapsed single-message view
      description: |
        The original's identity + CURRENT status, per-status `timestamps`, frozen
        `compliance_snapshot`, full `body` and the ordered `transitions` rows.
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: The collapsed view.
          content:
            application/json:
              schema:
                type: object
                required: [sms_message]
                properties:
                  sms_message: { $ref: "#/components/schemas/SmsMessageView" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
  # ── SMS providers ───────────────────────────────────────────────────────
  /v1/sms/providers:
    get:
      operationId: listSmsProviders
      tags: [sms-providers]
      summary: SMS provider registry
      responses:
        "200":
          description: Own rows + inherited empresa-wide rows.
          content:
            application/json:
              schema:
                type: object
                required: [sms_providers]
                properties:
                  sms_providers:
                    type: array
                    items: { $ref: "#/components/schemas/SmsProvider" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      operationId: createSmsProvider
      tags: [sms-providers]
      summary: Register an SMS provider
      description: |
        Carriers conventions: `scope: "empresa"` creates an account-wide row
        (tenant_id null) and requires an `account_admin` user (see `User.role`) —
        a `member` or a machine key gets **403**; `account_id`/`tenant_id` are
        injected from the session, never the body; the scope of a row is
        immutable after creation.
        `webhook_secret` is WRITE-ONLY — responses carry `has_secret`, never the
        secret. For `telnyx`, `webhook_secret` holds the account's Ed25519 PUBLIC
        key (base64) and sends authenticate with the deployment-wide
        `TELNYX_API_KEY` bearer. Retire-only — no DELETE route exists.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [adapter, region, name]
              properties:
                adapter: { type: string, enum: [http, telnyx, mock] }
                region: { type: string, enum: [us, mx, eu] }
                name: { type: string }
                base_url:
                  type: string
                  description: >-
                    Internal-contract (`http`) adapter endpoint. MUST be an `https://`
                    URL **on port 443** whose host resolves only to PUBLIC addresses —
                    loopback, RFC1918, CGNAT (100.64/10), link-local (169.254/16, cloud
                    metadata) and their IPv6 equivalents are refused `422` (SSRF guard),
                    and the same check re-runs at send time against a fresh resolution.
                    A port other than 443 is refused `422` even when the host is public:
                    a public address is not a public service, and reaching an arbitrary
                    port of it is reachability we do not grant. Reaching a destination
                    that legitimately serves elsewhere needs an operator allowlist entry,
                    not a tenant field. Blank means "use the deployment-wide endpoint".
                webhook_secret:
                  type: string
                  description: Write-only. HMAC secret (`http`/`mock`) or Ed25519 public key, base64 (`telnyx`).
                status: { type: string, enum: [active, disabled, retired] }
                scope:
                  type: string
                  enum: [sede, empresa]
                  default: sede
                  description: Request-only field; responses derive it from `tenant_id`.
              # `account_id`/`tenant_id` are NOT listed and NOT accepted: sending
              # either is a 400 that names it. Unlike `/v1/carriers`, which
              # documents `account_id` as accepted-and-ignored, this surface
              # never took them from the body, so the refusal is the contract.
              additionalProperties: false
            # `base_url` is BLANK on purpose. The SSRF guard resolves the host,
            # and `.test` is a reserved TLD (RFC 2606) that does not resolve and
            # never will, so the previous value answered `422` to anyone who
            # copied this example out of the reference. Blank is the case the
            # field's own description documents — "use the deployment-wide
            # endpoint" — and `base_url_errors/1` short-circuits on it before
            # reaching the guard, so unlike a public URL it does not depend on
            # any resolution succeeding. `webhook_secret` carries a
            # self-describing placeholder for the same reason the auth examples
            # do: a published example that fills a secret field with a
            # plausible-looking value teaches readers to keep it.
            example:
              adapter: http
              region: us
              name: example-sms-provider
              base_url: ""
              webhook_secret: example-hmac-secret-not-a-real-one
      responses:
        "201":
          description: Registered provider (`has_secret`, never the secret).
          content:
            application/json:
              schema:
                type: object
                required: [sms_provider]
                properties:
                  sms_provider: { $ref: "#/components/schemas/SmsProvider" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
  /v1/sms/providers/{id}:
    get:
      operationId: getSmsProvider
      tags: [sms-providers]
      summary: Fetch one provider
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: The provider.
          content:
            application/json:
              schema:
                type: object
                required: [sms_provider]
                properties:
                  sms_provider: { $ref: "#/components/schemas/SmsProvider" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      operationId: updateSmsProvider
      tags: [sms-providers]
      summary: Update a provider (scope immutable)
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                adapter: { type: string, enum: [http, telnyx, mock] }
                region: { type: string, enum: [us, mx, eu] }
                name: { type: string }
                base_url: { type: string }
                webhook_secret: { type: string, description: Write-only. }
                status: { type: string, enum: [active, disabled, retired] }
              # `scope` is absent HERE on purpose and the POST accepts it: the
              # scope of a row is immutable after creation, so a `scope` on this
              # PATCH is a 400 that names it, not a silent no-op.
              additionalProperties: false
            example:
              name: postman-sms-renamed
              status: disabled
      responses:
        "200":
          description: Updated provider.
          content:
            application/json:
              schema:
                type: object
                required: [sms_provider]
                properties:
                  sms_provider: { $ref: "#/components/schemas/SmsProvider" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
  /v1/sms/providers/{id}/retire:
    post:
      operationId: retireSmsProvider
      tags: [sms-providers]
      summary: Retire a provider (rows never delete)
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: The retired provider.
          content:
            application/json:
              schema:
                type: object
                required: [sms_provider]
                properties:
                  sms_provider: { $ref: "#/components/schemas/SmsProvider" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
  # ── inbound DID routes ──────────────────────────────────────────────────
  /v1/inbound-routes:
    get:
      operationId: listInboundRoutes
      tags: [inbound-routes]
      summary: List inbound routes
      responses:
        "200":
          description: The tenant's inbound routes.
          content:
            application/json:
              schema:
                type: object
                required: [inbound_routes]
                properties:
                  inbound_routes:
                    type: array
                    items: { $ref: "#/components/schemas/InboundRoute" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      operationId: createInboundRoute
      tags: [inbound-routes]
      summary: Create an inbound route
      description: "`e164` is immutable after creation; `tenant_id` is injected by the router."
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              # CLOSED (MT-SEC-49 lote 3): an unrecognized key is `400 bad_request` naming it, never a silent drop.
              additionalProperties: false
              required: [e164, dest_type]
              properties:
                e164: { type: string, description: E.164 (immutable after creation). }
                dest_type:
                  type: string
                  enum: [agent, queue, ivr]
                  description: "`agent` bridges straight to the registered agent; `ivr` runs the IVR engine on FreeSWITCH; `queue` anchors the leg on FreeSWITCH. Today the production FreeSWITCH image ships no queue extensions (ADR #104 D13: the park pen and `dd_queue_hold` are a pending `freeswitch` dependency, not yet delivered), so a queue leg is hung up by FreeSWITCH as `UNALLOCATED_NUMBER` regardless of the flag. Once they ship: with the tenant's `inbound_queue_stream_enabled` flag ON, `core` admits the call to the route's queue (`enqueued`), the extension answers with a generated tone, the owner offers it to the oldest Ready agent and bridges it (MT-CTI-17 FASE 3); flag OFF (default): the leg ends unanswered in the pen."
                dest_ref: { type: string }
                recording: { type: boolean }
                ai_enabled: { type: boolean }
                notes: { type: string }
            example:
              e164: "+13125550171"
              dest_type: ivr
      responses:
        "201":
          description: Created route.
          content:
            application/json:
              schema:
                type: object
                required: [inbound_route]
                properties:
                  inbound_route: { $ref: "#/components/schemas/InboundRoute" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "409": { $ref: "#/components/responses/Conflict" }
  /v1/inbound-routes/{id}:
    get:
      operationId: getInboundRoute
      tags: [inbound-routes]
      summary: Fetch one inbound route
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: The route.
          content:
            application/json:
              schema:
                type: object
                required: [inbound_route]
                properties:
                  inbound_route: { $ref: "#/components/schemas/InboundRoute" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      operationId: updateInboundRoute
      tags: [inbound-routes]
      summary: Update an inbound route (e164 immutable)
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              # CLOSED (MT-SEC-49 lote 3): an unrecognized key is `400 bad_request` naming it, never a silent drop.
              additionalProperties: false
              properties:
                dest_type:
                  type: string
                  enum: [agent, queue, ivr]
                  description: "`agent` bridges straight to the registered agent; `ivr` runs the IVR engine on FreeSWITCH; `queue` anchors the leg on FreeSWITCH. Today the production FreeSWITCH image ships no queue extensions (ADR #104 D13: the park pen and `dd_queue_hold` are a pending `freeswitch` dependency, not yet delivered), so a queue leg is hung up by FreeSWITCH as `UNALLOCATED_NUMBER` regardless of the flag. Once they ship: with the tenant's `inbound_queue_stream_enabled` flag ON, `core` admits the call to the route's queue (`enqueued`), the extension answers with a generated tone, the owner offers it to the oldest Ready agent and bridges it (MT-CTI-17 FASE 3); flag OFF (default): the leg ends unanswered in the pen."
                dest_ref: { type: string }
                recording: { type: boolean }
                ai_enabled: { type: boolean }
                status: { type: string, enum: [active, disabled] }
                notes: { type: string }
            example:
              recording: false
      responses:
        "200":
          description: Updated route.
          content:
            application/json:
              schema:
                type: object
                required: [inbound_route]
                properties:
                  inbound_route: { $ref: "#/components/schemas/InboundRoute" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
  /v1/inbound-routes/{id}/provision:
    post:
      operationId: provisionInboundRoute
      tags: [inbound-routes]
      summary: Push the route to the SIP edge
      description: |
        `409` when the route is disabled; `422` when the destination agent cannot be
        resolved for this sede or has no SIP extension (seat) to deliver to;
        `200 {"status": "sbc_disabled"}` when no `KAMAILIO_RPC_URL` is configured.
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: Provisioned (or SBC disabled in this environment).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SbcStatus" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "502": { $ref: "#/components/responses/SbcError" }
  /v1/inbound-routes/{id}/disable:
    post:
      operationId: disableInboundRoute
      tags: [inbound-routes]
      summary: Disable an inbound route
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: The disabled route.
          content:
            application/json:
              schema:
                type: object
                required: [inbound_route]
                properties:
                  inbound_route: { $ref: "#/components/schemas/InboundRoute" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
  # ── platform-admin surface (separate trust domain) ──────────────────────
  /v1/admin/tenants:
    post:
      operationId: adminProvisionTenant
      tags: [admin]
      summary: Provision a tenant (onboarding backbone)
      description: |
        Provisions a new tenant + its first admin agent + a bootstrap API key in one
        call. SEPARATE trust domain (it predates any tenant), gated by the
        platform-admin bearer (`ADMIN_API_TOKEN`). FAIL-CLOSED: when the admin token
        is unset, every `/v1/admin/*` request is `401`. The returned token is the
        tenant's first credential — plaintext shown ONCE. `400/422` on an invalid
        payload (no tenant is created).
      security:
        - adminApiToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, admin]
              properties:
                name: { type: string }
                tier:
                  type: string
                  enum: [starter, growth, scale, enterprise, dialer_core, compliance_pro, audit_shield]
                  default: dialer_core
                voice_ai_enabled:
                  type: boolean
                  description: >-
                    Offered on the tiers whose catalog entry declares a voice-AI
                    allowance (`growth`, `scale`, `enterprise`, and the retired
                    `compliance_pro` / `audit_shield`); enabling it on a tier
                    that does not offer it — `starter` or `dialer_core` — is
                    rejected.
                seats: { type: integer }
                admin:
                  type: object
                  required: [name, email]
                  properties:
                    name: { type: string }
                    email: { type: string, format: email }
                    sip_extension:
                      type: string
                      pattern: '^[0-9A-Za-z._-]+$'
            example:
              name: Acme Collections
              tier: scale
              voice_ai_enabled: false
              seats: 10
              admin: { name: Owner, email: owner@acme.test }
      responses:
        "201":
          description: Tenant + first admin agent + bootstrap key (plaintext ONCE).
          content:
            application/json:
              schema:
                type: object
                required: [tenant, admin_agent, api_key]
                properties:
                  tenant: { $ref: "#/components/schemas/Tenant" }
                  admin_agent: { $ref: "#/components/schemas/Agent" }
                  api_key:
                    type: object
                    required: [token]
                    properties:
                      token: { type: string, description: "Bootstrap bearer, shown exactly once." }
              example:
                tenant: { id: 0d4f4f9e-1f2a-4b53-9d3c-8a5e2f7b1c10, name: Acme Collections, status: active, tier: scale, voice_ai_enabled: false, seats: 10, retention_months: 84, ai_monthly_budget_usd: null, ai_max_call_seconds: null, ai_max_call_seconds_effective: 900, sms_monthly_budget_usd: null, created_at: "2026-07-05T10:00:00Z", updated_at: "2026-07-05T10:00:00Z" }
                admin_agent: { id: 1b2c3d4e-5f6a-4b7c-8d9e-0f1a2b3c4d5e, tenant_id: 0d4f4f9e-1f2a-4b53-9d3c-8a5e2f7b1c10, name: Owner, email: owner@acme.test, role: admin, status: active, sip_extension: null, device_mode: external, presence: offline, created_at: "2026-07-05T10:00:00Z", updated_at: "2026-07-05T10:00:00Z" }
                api_key: { token: dd_example_bootstrap_shown_once }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/AdminAuthThrottled" }
        "409": { $ref: "#/components/responses/Conflict" }
  /v1/admin/tenants/{id}:
    patch:
      operationId: adminUpdateTenant
      tags: [admin]
      summary: Change plan / billing-bearing fields
      description: |
        The billing-bearing fields a tenant must NOT be able to self-edit
        (under-pay vector): `tier`, `voice_ai_enabled`, `seats`, plus the
        cost-integrity ceilings (`ai_monthly_budget_usd`, `ai_max_call_seconds`,
        `sms_monthly_budget_usd`; null = no cap — except `ai_max_call_seconds`,
        where null = the platform default applies, core#922). The Tenant changeset still
        enforces the tier vocabulary and the voice-AI eligibility rule.
      security:
        - adminApiToken: []
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                tier: { type: string, enum: [starter, growth, scale, enterprise, dialer_core, compliance_pro, audit_shield] }
                voice_ai_enabled: { type: boolean }
                seats: { type: integer }
                ai_monthly_budget_usd:
                  oneOf: [{ type: number }, { type: "null" }]
                ai_max_call_seconds:
                  oneOf: [{ type: integer }, { type: "null" }]
                  description: >-
                    `null` clears the tenant's own ceiling and puts it back on the
                    platform default (the response's `ai_max_call_seconds_effective`
                    shows the number that will apply). There is no "no ceiling".
                sms_monthly_budget_usd:
                  oneOf: [{ type: number }, { type: "null" }]
                human_initiated_enabled:
                  type: boolean
                  description: |
                    D17 human-initiated consent posture (default `false`).
                    ADMIN-ONLY: it is compliance-loosening, so it is never
                    writable on the tenant self-edit `PATCH /v1/tenant`.
                max_cps:
                  oneOf: [{ type: integer, minimum: 1 }, { type: "null" }]
                  description: Per-tenant CPS ceiling (ADR #12; null = no ceiling, > 0).
                max_concurrent_channels:
                  oneOf: [{ type: integer, minimum: 1 }, { type: "null" }]
                  description: Per-tenant concurrent-channel ceiling (ADR #12; null = no ceiling, > 0).
                abandon_seller_name:
                  oneOf: [{ type: string, minLength: 1, maxLength: 200 }, { type: "null" }]
                  description: >-
                    Seller name spoken by the FTC TSR §310.4(b)(4)(iii) abandoned-call
                    identification message (ADR #14). Admin-only. Null = not configured;
                    the predictive over-dial stays dark (seat-first) until BOTH
                    abandon_seller_name and abandon_seller_phone are set.
                abandon_seller_phone:
                  oneOf: [{ type: string, pattern: '^\+[1-9][0-9]{7,14}$' }, { type: "null" }]
                  description: >-
                    E.164 callback number spoken by the abandoned-call identification
                    message (must accept do-not-call requests during business hours).
                    Admin-only; null = not configured (over-dial stays dark).
                tax_country:
                  oneOf: [{ type: string, pattern: "^[A-Z]{2}$" }, { type: "null" }]
                  description: >-
                    ISO-3166-1 alpha-2 country of the client's sales-tax jurisdiction
                    (MT-BIL-07 (3), ADR #77). Admin-only: a tenant that could declare
                    its own jurisdiction could pick its own tax. The shape is validated,
                    the list of countries is NOT. Captured only — no invoice amount
                    changes because of this field.
                tax_region:
                  oneOf: [{ type: string, minLength: 1, maxLength: 10 }, { type: "null" }]
                  description: >-
                    State/province code of the sales-tax jurisdiction (ADR #77).
                    Admin-only; null = not declared.
                tax_postal_code:
                  oneOf: [{ type: string, minLength: 1, maxLength: 20 }, { type: "null" }]
                  description: >-
                    Postal code of the sales-tax jurisdiction (ADR #77). Admin-only;
                    null = not declared.
            example:
              tier: enterprise
              voice_ai_enabled: true
              seats: 25
              abandon_seller_name: Acme Recovery LLC
              abandon_seller_phone: "+18005550123"
      responses:
        "200":
          description: Updated tenant.
          content:
            application/json:
              schema:
                type: object
                required: [tenant]
                properties:
                  tenant: { $ref: "#/components/schemas/Tenant" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/AdminAuthThrottled" }
        "404": { $ref: "#/components/responses/NotFound" }
  /v1/admin/tenants/{id}/dids:
    post:
      operationId: adminAssignHouseDid
      tags: [admin]
      summary: Assign a HOUSE number to a tenant
      description: |
        Same metadata surface as the tenant-facing `POST /v1/dids` EXCEPT
        origin/status: `origin` is pinned to `house` by the context and a fresh
        assignment is always `active`. The tenant-facing route can only ever create
        `origin: customer` — rentable inventory is admin-minted ONLY. A number
        already active in ANY sede is a clean `409` (global partial unique index).
      security:
        - adminApiToken: []
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              # CLOSED (MT-SEC-49 lote 3): an unrecognized key is `400 bad_request` naming it, never a silent drop.
              additionalProperties: false
              required: [e164]
              properties:
                e164: { type: string }
                npa: { type: string }
                us_state: { type: string }
                attestation: { type: string, enum: [A, B, C, unknown] }
                labels:
                  type: array
                  items: { type: string }
                notes: { type: string }
            example:
              e164: "+13125550600"
              us_state: IL
              attestation: A
              labels: [house-pool]
      responses:
        "201":
          description: "Assigned house DID (`origin: house`, `status: active`)."
          content:
            application/json:
              schema:
                type: object
                required: [did]
                properties:
                  did: { $ref: "#/components/schemas/DID" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/AdminAuthThrottled" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
  # The step between "tenant exists" and "tenant may place traffic" (ADR #42).
  # `POST /v1/admin/tenants` creates NO plan row, and PlanGate is armed
  # unconditionally (ADR #39), so a tenant without one is dark. The payment
  # route below APPLIES a payment to a plan; it does not create one.
  /v1/admin/tenants/{id}/plan:
    post:
      operationId: adminOpenTenantPlan
      tags: [admin]
      summary: Open the tenant's plan and choose its billing scheme
      description: |
        Creates the plan anchor if absent (idempotent `draft`) and commits the
        billing scheme. `subscription` and `prepaid` land on `pending_payment`;
        `postpaid` opens an operator approval and lands on `pending_approval`.
        The plan is NOT active yet — `POST /v1/admin/tenants/{id}/payments`
        activates a `pending_payment` plan.

        `tier_key` is required for `subscription` and must name a SELLABLE,
        priced tier; a retired or unpriced key is refused `422` rather than
        silently selecting a different one.

        Admin-only, like every plan-bearing surface: a tenant must never choose
        its own billing posture.
      security:
        - adminApiToken: []
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [scheme]
              properties:
                scheme:
                  type: string
                  enum: [subscription, prepaid, postpaid]
                tier_key:
                  type: string
                  description: "Required for `subscription`; ignored otherwise."
            example:
              scheme: subscription
              tier_key: scale
      responses:
        "201":
          description: "Plan opened and scheme committed."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TenantPlanEnvelope"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "429": { $ref: "#/components/responses/AdminAuthThrottled" }
    get:
      operationId: adminGetTenantPlan
      tags: [admin]
      summary: The tenant's plan anchor
      description: |
        `status` is the field that decides whether the tenant may place traffic:
        anything other than `active` is dark. The paid-period fields stay null
        until a payment lands, so "opened but not paid" is distinguishable from
        "paid" at a glance. `404 no_plan` when the plan has never been opened.
      security:
        - adminApiToken: []
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: "The plan anchor row."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TenantPlanEnvelope"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/AdminAuthThrottled" }

  /v1/admin/tenants/{id}/plan/alerts:
    put:
      operationId: adminSetTenantPlanAlerts
      tags: [admin]
      summary: Set the tenant's spend-alert thresholds and destinations
      description: |
        The tenant is warned as its spend crosses `thresholds` (% points of the
        plan limit). The customer tunes WHEN it is warned, never WHETHER it is
        blocked — `100` is always implied by the emitter even if removed here,
        and blocking is decided by the enforcement posture, not by this route.

        `thresholds` is REQUIRED. Each destination is OPTIONAL and the asymmetry
        is deliberate:

          * **omit** `email` / `webhook_url` → the stored value is PRESERVED, so
            retuning thresholds can never blind a tenant's alerting;
          * send it as `null` → the destination is CLEARED;
          * send an address / URL → it is written.

        Both destinations null means log-only, which is the state of every plan
        opened before the alert e-mail landed. A malformed address is refused
        `422` and NOTHING is written — not the thresholds either.

        Only an `active` plan can be tuned; anything else is `409`. Note the
        request/response asymmetry: the body names the destinations `email` and
        `webhook_url`, while the plan echoes the stored columns `alert_email`
        and `alert_webhook_url`.

        Admin-only, like every plan-bearing surface. There is no screen for it:
        the dashboard proxy denies the whole `/v1/admin` prefix, so this is
        runbook surface.
      security:
        - adminApiToken: []
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [thresholds]
              properties:
                thresholds:
                  type: array
                  description: |
                    Non-empty, strictly ascending, every element in 1..100.
                  items: { type: integer, minimum: 1, maximum: 100 }
                email:
                  oneOf: [{ type: string, format: email }, { type: "null" }]
                  description: "Omit to preserve; `null` to clear."
                webhook_url:
                  oneOf: [{ type: string, format: uri }, { type: "null" }]
                  description: "Omit to preserve; `null` to clear."
            example:
              thresholds: [80, 90, 100]
              email: billing@acme.test
      responses:
        "200":
          description: "Alert config stored; the plan echoes it back."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TenantPlanEnvelope"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "429": { $ref: "#/components/responses/AdminAuthThrottled" }

  /v1/admin/tenants/{id}/plan/enforcement-mode:
    put:
      operationId: adminSetTenantPlanEnforcementMode
      tags: [admin]
      summary: Set the plan's enforcement posture
      description: |
        Chooses what happens when the tenant's spend crosses one of its alert
        thresholds:

          * `graduated` → the crossing NOTIFIES the tenant (e-mail / webhook,
            per `PUT .../plan/alerts`);
          * `hard_block` → the crossing is recorded and nothing is sent.

        The threshold history is written in BOTH postures; only the notification
        differs. What this route does NOT do is as important as what it does: it
        never moves a limit, never retunes a threshold and never softens the
        100% verdict — spend enforcement blocks at the limit in both postures.
        There is no "off".

        `mode` is REQUIRED and the vocabulary is closed: anything else — an
        absent key, a wrong word, a non-string — is refused `422` and the plan
        is left untouched. Only an `active` plan has a posture to set; anything
        else is `409`, and a tenant whose plan was never opened is `404
        no_plan`.

        Admin-only, like every plan-bearing surface. There is no screen for it:
        the dashboard proxy denies the whole `/v1/admin` prefix, so this is
        runbook surface.
      security:
        - adminApiToken: []
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [mode]
              properties:
                mode:
                  type: string
                  enum: [hard_block, graduated]
            example:
              mode: graduated
      responses:
        "200":
          description: "Posture stored; the plan echoes it back."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TenantPlanEnvelope"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "429": { $ref: "#/components/responses/AdminAuthThrottled" }

  /v1/admin/tenants/{id}/payments:
    post:
      operationId: adminRecordManualPayment
      tags: [admin]
      summary: Record a MANUAL payment (wire/cash/check) for a tenant
      description: |
        An operator records an off-platform payment the tenant made; the durable
        `payments` audit fact lands (exactly-once on `payment_ref`) and, if the plan
        accepts it, the plan activates / the wallet is credited
        (`Dialer.Payments.confirm_manual/3`). Idempotent on `payment_ref` — a replay
        neither double-records nor double-credits. `amount_usd` MUST be a decimal
        STRING (a JSON number is refused `400`). A refused edge (below the plan
        minimum; a renewal on an already-active subscription) still records the
        audit fact and returns `422` — never a wrong money movement.
      security:
        - adminApiToken: []
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [payment_ref, amount_usd, kind]
              properties:
                payment_ref:
                  type: string
                  description: "OUR-side idempotency key (the wire/SPEI/check reference)."
                amount_usd:
                  type: string
                  description: "Decimal STRING (never a JSON number)."
                kind:
                  type: string
                  enum: [subscription_period, prepaid_topup, postpaid_deposit]
                currency: { type: string, default: USD }
            example:
              payment_ref: "wire-2026-07-07-001"
              amount_usd: "50.00"
              kind: prepaid_topup
      responses:
        "201":
          description: "Payment recorded and applied."
          content:
            application/json:
              schema:
                type: object
                required: [payment]
                properties:
                  payment:
                    type: object
                    properties:
                      status: { type: string, example: applied }
                      plan_status: { type: string, example: active }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "429": { $ref: "#/components/responses/AdminAuthThrottled" }

  # Governed flag/config catalog (ADR #29, roadmap R19). Admin-only on purpose:
  # several catalog keys are compliance-loosening or money-bearing, so a tenant
  # must never be able to write its own posture.
  /v1/admin/tenants/{id}/config:
    get:
      operationId: getTenantConfig
      tags: [admin]
      summary: Effective governed config for a tenant
      description: |
        For every key declared in the governed catalog: the value its CONSUMER
        actually sees, with its provenance (`source`), WHERE it is read
        (`consumer`), whether a per-tenant override applies at all
        (`override_honoured`), and any stored override that is NOT being applied
        (`ignored_override`).

        Not every declared key resolves through the override table. A key whose
        consumer still reads application config reports the application-config
        value, and a row stored against it is reported as `ignored_override`
        rather than as the effective value — so a knob can never read back as
        armed while the code acting on it sees something else. For a key that IS
        resolved here, precedence is `catalog default <- segment <- tenant`.

        The catalog default is also the FAIL-SAFE value: if the config table
        cannot be read, resolution falls back to it rather than raising or
        enabling anything.
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        "200":
          description: The effective catalog for this tenant.
          content:
            application/json:
              schema:
                type: object
                required: [config]
                properties:
                  config:
                    type: array
                    items: { $ref: "#/components/schemas/ConfigEntry" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/AdminAuthThrottled" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/admin/tenants/{id}/config/{key}:
    put:
      operationId: putTenantConfigOverride
      tags: [admin]
      summary: Set a per-tenant override for a governed key
      description: |
        Stores a tenant override for a DECLARED catalog key and records the change
        in the append-only audit trail (the write and its audit row commit
        together: if the trail cannot be written, the override does not happen).

        The key must exist in the catalog (`422 unknown_config_key`), the value
        must match its declared type (`422 invalid_config_value`), and the key's
        consumer must actually READ the override table
        (`409 config_override_not_honoured`).

        That last one is the point: storing an override nobody reads is not a
        harmless no-op — it made this endpoint report a money brake as armed while
        the code spending the money still saw it off. A write that cannot change
        behaviour is refused rather than accepted with a caveat. Check
        `override_honoured` on the `GET` before writing; when it is `false`, set
        the application-config key named in `consumer` on the deployment instead.
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
        - { name: key, in: path, required: true, schema: { type: string }, description: A key declared in the governed catalog. }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [value]
              properties:
                value:
                  description: "Must match the key's declared type (boolean or integer)."
                  oneOf: [{ type: boolean }, { type: integer }]
            example: { value: true }
      responses:
        "200":
          description: Override stored; the whole effective catalog is returned.
          content:
            application/json:
              schema:
                type: object
                required: [config]
                properties:
                  config:
                    type: array
                    items: { $ref: "#/components/schemas/ConfigEntry" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: |
            `config_override_not_honoured` — the key is declared and the value is
            well typed, but this key's consumer reads application config, so the
            override would be inert and is refused. Distinct from the `422`s on
            purpose: nothing is wrong with the request, the target's current state
            cannot accept it. Migrating the consumer makes the identical request
            succeed.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
              example:
                error:
                  code: config_override_not_honoured
                  message: "this key's consumer reads application config (:spend_guard_enforce), not the per-tenant override table, so the override would be inert and is refused. Set it through the deployment's application config instead."
        "422":
          description: |
            `unknown_config_key` — the key is not declared in the catalog, or
            `invalid_config_value` — the value does not match its declared type.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
              example:
                error: { code: unknown_config_key, message: "no such key in the governed catalog" }
        "429": { $ref: "#/components/responses/AdminAuthThrottled" }
    delete:
      operationId: deleteTenantConfigOverride
      tags: [admin]
      summary: Drop a per-tenant override (back to the catalog default)
      description: |
        Deactivates the override so the key resolves to its catalog default. The
        row is DEACTIVATED, never deleted (`DELETE` is revoked for the app role):
        the record that this tenant once carried an override is itself evidence.
        Idempotent — dropping an absent override is a `200`.

        Unlike `PUT`, this is allowed even when `override_honoured` is `false`:
        it is the only way to clear a row that is reported as `ignored_override`,
        and gating removal on the same condition that makes a row inert would
        strand exactly the rows that need clearing.
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
        - { name: key, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: Override dropped (or already absent); effective catalog returned.
          content:
            application/json:
              schema:
                type: object
                required: [config]
                properties:
                  config:
                    type: array
                    items: { $ref: "#/components/schemas/ConfigEntry" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422":
          description: "`unknown_config_key` — the key is not declared in the catalog."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "429": { $ref: "#/components/responses/AdminAuthThrottled" }

  /v1/admin/tenants/{id}/config-audit:
    get:
      operationId: getTenantConfigAudit
      tags: [admin]
      summary: Who changed which governed knob, from what to what
      description: |
        The append-only trail of config changes for this tenant, newest first.
        Append-only at the DATABASE level (a trigger rejects UPDATE/DELETE and the
        grants revoke them), so the process that writes the trail cannot rewrite
        it.
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        "200":
          description: The change trail.
          content:
            application/json:
              schema:
                type: object
                required: [audit]
                properties:
                  audit:
                    type: array
                    items: { $ref: "#/components/schemas/ConfigAuditEntry" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/AdminAuthThrottled" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/admin/tenants/{id}/design-partner-discounts:
    get:
      operationId: listDesignPartnerDiscounts
      tags: [admin]
      summary: The tenant's design-partner discount history, plus the grant in force
      description: |
        Every grant ever appended for this tenant, newest first, plus the one
        in force today (`null` when there is none).

        The superseded rows ARE the audit trail — a month that already closed
        was billed against the grant in force then — so the history is served
        whole rather than filtered down to what applies now. `in_force` is
        DERIVED on every read and never stored, so it cannot drift from it.

        Admin-only, like every surface that decides an invoice.
      security:
        - adminApiToken: []
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: The grants.
          content:
            application/json:
              schema:
                type: object
                required: [discounts, in_force, as_of]
                properties:
                  discounts:
                    type: array
                    items: { $ref: "#/components/schemas/DesignPartnerDiscount" }
                  in_force:
                    description: The grant applying today, or null.
                    oneOf:
                      - $ref: "#/components/schemas/DesignPartnerDiscount"
                      - type: "null"
                  as_of:
                    type: string
                    format: date
                    description: The date `in_force` was resolved at.
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/AdminAuthThrottled" }
    post:
      operationId: grantDesignPartnerDiscount
      tags: [admin]
      summary: Grant the design-partner discount on the voice-AI add-on
      description: |
        APPENDS a discount grant on the voice-AI add-on (`core#220`). The
        pricing page promises design partners «20% off for 6 months»; this is
        the only surface that can give it to anybody.

        ADMIN-ONLY, and for the reason the whole `/v1/admin/tenants/{id}`
        family exists: a discount decides the invoice as directly as the tier
        does, so a tenant that could write its own would be under-paying by
        API. `PATCH /v1/tenant` accepts `name` and nothing else, and a discount
        field sent there is ignored like every other billing-bearing key.

        There is deliberately no PUT and no DELETE: ending a grant EARLY is a
        new row with `percent: 0` and a later `effective_from`, because a month
        that already closed has to keep reading the grant that applied to it.

        The window is closed at BOTH ends and it is REQUIRED. Send exactly one
        of `effective_to` (an explicit end, for a negotiated term) or `months`
        (the published duration counted from `effective_from`); neither is a
        400 rather than a grant with no end, and both is a 400 rather than one
        of the two silently winning.
      security:
        - adminApiToken: []
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [percent, granted_by]
              properties:
                percent:
                  type: string
                  description: |
                    Percentage points off the voice-AI subtotal, as a DECIMAL
                    STRING (never a JSON float: binary floats are refused
                    outright by the changeset, as everywhere else money is
                    handled). `0` is legal and is how a grant is ended early;
                    outside `[0, 100]` is a 400.
                  example: "20"
                effective_from:
                  type: string
                  format: date
                  description: |
                    The day the grant starts applying. Defaults to today. A
                    future date schedules it without retro-applying it.
                effective_to:
                  type: string
                  format: date
                  description: |
                    The LAST day the grant applies (inclusive). Mutually
                    exclusive with `months`.
                months:
                  type: integer
                  minimum: 1
                  description: |
                    The grant's duration in calendar months from
                    `effective_from`, the way the published offer states it
                    (6). Mutually exclusive with `effective_to`.

                    No `maximum` is declared because the real ceiling is not a
                    constant: `effective_from + months` must land inside the
                    range a `date` column holds (PostgreSQL: 4713 BC ..
                    5874897 AD), so how large `months` may be depends on the
                    start date. A value that pushes `effective_to` past it is
                    a 400 `invalid_months` naming the year it refused.
                  example: 6
                granted_by:
                  type: string
                  minLength: 1
                  description: |
                    Who granted it and on what basis. Required: a discount
                    moves money, so no row is unsigned and the machine never
                    grants one.
                  example: "founder — design-partner agreement 2026-09"
                note:
                  type: string
      responses:
        "201":
          description: The grant as stored.
          content:
            application/json:
              schema:
                type: object
                required: [discount]
                properties:
                  discount: { $ref: "#/components/schemas/DesignPartnerDiscount" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/AdminAuthThrottled" }

  /v1/admin/tax-nexus:
    get:
      operationId: listTaxNexus
      tags: [admin]
      summary: Every tax-nexus declaration, plus the subset in force today
      description: |
        The jurisdictions where the company has declared tax nexus (ADR #80
        decision 3). Returns the WHOLE append-only history in `nexus`, newest
        first, and the derived subset in force today in `in_force`.

        The superseded rows are the audit trail — a month that already closed
        was closed against the declaration in force then, so hiding them would
        defeat the reason the table is append-only. `in_force` is DERIVED on
        every read, never stored, so it cannot drift from the history.

        Admin-only, with no tenant-facing counterpart even for reading: nexus
        is a fact about the COMPANY, not about a customer, so exposing it per
        tenant would publish the platform's fiscal posture to every account.

        An EMPTY list means "no nexus declared anywhere", which is the truth
        until the founder declares the first state — never "unknown".
      responses:
        "200":
          description: The declarations.
          content:
            application/json:
              schema:
                type: object
                required: [nexus, in_force, as_of]
                properties:
                  nexus:
                    type: array
                    items: { $ref: "#/components/schemas/TaxNexusState" }
                  in_force:
                    type: array
                    items: { $ref: "#/components/schemas/TaxNexusState" }
                  as_of:
                    type: string
                    format: date
                    description: The date `in_force` was resolved at.
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/AdminAuthThrottled" }
    post:
      operationId: declareTaxNexus
      tags: [admin]
      summary: Declare tax nexus in a jurisdiction
      description: |
        APPENDS a declaration (ADR #80 decision 3). There is deliberately no
        PUT and no DELETE: ending a nexus is a NEW row with `active: false`
        and a later `effective_from`, because a month that already closed has
        to keep reading the declaration that was in force when it closed.

        The machine never infers nexus from revenue thresholds — that would be
        a tax engine that is wrong silently — so `declared_by` is required and
        must name the human or the ruling behind the row.

        `country` and `region` are upcased before storing, so `ca` and `CA` are
        the same jurisdiction and collide instead of becoming two rows.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [country, region, declared_by]
              properties:
                country:
                  type: string
                  pattern: "^[A-Za-z]{2}$"
                  description: ISO-3166 alpha-2. Shape, not a closed list.
                  example: US
                region:
                  type: string
                  minLength: 1
                  maxLength: 10
                  description: State/province code as the jurisdiction writes it.
                  example: CA
                effective_from:
                  type: string
                  format: date
                  description: |
                    The day the declaration starts applying. Defaults to today.
                    A future date schedules the change without retro-applying it.
                active:
                  type: boolean
                  default: true
                  description: |
                    `false` declares that nexus ENDED on `effective_from`.
                declared_by:
                  type: string
                  minLength: 1
                  description: Who declared it, and on what basis.
                  example: "asesoría fiscal Ruiz — dictamen 2026-09"
                note:
                  type: string
      responses:
        "201":
          description: The declaration as stored.
          content:
            application/json:
              schema:
                type: object
                required: [nexus]
                properties:
                  nexus: { $ref: "#/components/schemas/TaxNexusState" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/AdminAuthThrottled" }

  # ── voice-ai ───────────────────────────────────────────────────────────────
  /v1/ai/agents:
    get:
      operationId: listAiAgents
      tags: [voice-ai]
      summary: List AI voice agents
      responses:
        "200":
          description: All AI agents of the tenant.
          content:
            application/json:
              schema:
                type: object
                required: [ai_agents]
                properties:
                  ai_agents:
                    type: array
                    items: { $ref: "#/components/schemas/AiAgent" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      operationId: createAiAgent
      tags: [voice-ai]
      summary: Create an AI agent (status `draft`)
      description: |
        Creates a `draft` agent at `policy_version: 1`. The opening `disclosure.text`
        is mandated, content-validated (must self-identify the voice as automated,
        EN/ES) and becomes IMMUTABLE the first time the agent transitions to `live`. A
        `human_requested` handoff rule is always present and `locked`. Duplicate `name`
        → `409`. Management scope: a `ddw_` browser token is `403`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/AiAgentWrite" }
            example:
              name: "Ava — Collections Assistant"
              voice: { language: en-US, voice_id: neural-3-female, pace: 0.5 }
              disclosure:
                text: "This call is from Ava, an automated assistant calling on behalf of Brightpath Recovery Group regarding an account. This call is recorded."
              policy:
                max_settlement_pct: 70
                min_payment_cents: 5000
                max_plan_months: 12
                take_payments: card_on_file
                prohibited: ["legal threats", "third-party disclosure", "other debts"]
              handoff_rules:
                - trigger: keywords
                  params: { keywords: [attorney, lawyer, dispute, cease, bankruptcy] }
                  action: transfer
                  severity: hard
      responses:
        "201":
          description: Created (status `draft`).
          content:
            application/json:
              schema:
                type: object
                required: [ai_agent]
                properties:
                  ai_agent: { $ref: "#/components/schemas/AiAgent" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }

  /v1/ai/agents/{id}:
    get:
      operationId: getAiAgent
      tags: [voice-ai]
      summary: Fetch an AI agent
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: The AI agent.
          content:
            application/json:
              schema:
                type: object
                required: [ai_agent]
                properties:
                  ai_agent: { $ref: "#/components/schemas/AiAgent" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      operationId: updateAiAgent
      tags: [voice-ai]
      summary: Update an AI agent (bumps policy_version)
      description: |
        Any change to voice / disclosure / policy / handoff_rules / status INCREMENTS
        `policy_version` and appends an immutable snapshot (transcripts cite the version
        in force); a name-only edit does not bump. Constraints: once the agent has been
        `live`, `disclosure.text` is immutable (`422`); `policy_version` is monotonic;
        the `human_requested` handoff rule stays `locked`. `status` moves within
        `draft|live|paused|retired`. Management scope: a `ddw_` browser token is `403`.
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/AiAgentWrite" }
            example:
              status: live
              policy: { max_settlement_pct: 65 }
      responses:
        "200":
          description: The updated agent (new `policy_version`).
          content:
            application/json:
              schema:
                type: object
                required: [ai_agent]
                properties:
                  ai_agent: { $ref: "#/components/schemas/AiAgent" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/Unprocessable" }
    delete:
      operationId: retireAiAgent
      tags: [voice-ai]
      summary: Retire an AI agent (never deletes)
      description: |
        Soft-retire (status `retired`): the row and all its version snapshots survive
        because live/historical conversations and transcripts reference them. A retired
        agent cannot dial. Management scope: a `ddw_` browser token is `403`.
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: The retired agent.
          content:
            application/json:
              schema:
                type: object
                required: [ai_agent]
                properties:
                  ai_agent: { $ref: "#/components/schemas/AiAgent" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/ai/agents/{id}/test:
    post:
      operationId: testAiAgent
      tags: [voice-ai]
      summary: Sandbox conversation (no live call)
      description: |
        Drives the agent through a stateless SANDBOX exchange (proxied to the AI
        runtime) — NO real call, NO `ai_conversations` row, NO dial. Omit `session_id`
        to start; echo it back to continue the same sandbox thread. `503` when the
        runtime sandbox is unconfigured/unreachable. Management scope: a `ddw_` browser
        token is `403`.
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/AiTestRequest" }
            example:
              message: "Yes — I can pay $150 on the 27th."
      responses:
        "200":
          description: The agent's sandbox reply.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AiTestReply" }
              example:
                session_id: 7a8b9c0d-1e2f-3a4b-5c6d-7e8f9a0b1c2d
                reply: "I can set that up: $150 on June 27 using the card on file ending 4417. Shall I confirm?"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "502": { $ref: "#/components/responses/BadGateway" }
        "503": { $ref: "#/components/responses/SandboxUnavailable" }

  /v1/ai/conversations:
    get:
      operationId: listAiConversations
      tags: [voice-ai]
      summary: List AI conversations (live feed or history)
      description: |
        `active=true` returns the LIVE floor (conversations still in progress), newest
        first, UNPAGINATED. Without it, returns closed conversations, keyset-paginated
        (`from`/`to` bound `started_at`).
      parameters:
        - name: active
          in: query
          description: "`true` → only in-progress conversations (the live feed)."
          schema: { type: boolean }
        - $ref: "#/components/parameters/AiAgentIdFilter"
        - $ref: "#/components/parameters/FromFilter"
        - $ref: "#/components/parameters/ToFilter"
        - $ref: "#/components/parameters/LimitParam"
        - $ref: "#/components/parameters/CursorParam"
      responses:
        "200":
          description: A page/feed of conversations.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AiConversationPage" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/ai/conversations/{id}:
    get:
      operationId: getAiConversation
      tags: [voice-ai]
      summary: Fetch a conversation with transcript and outcome
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: The conversation, its full transcript and its outcome.
          content:
            application/json:
              schema:
                type: object
                required: [ai_conversation]
                properties:
                  ai_conversation: { $ref: "#/components/schemas/AiConversation" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/ai/conversations/{id}/takeover:
    post:
      operationId: takeoverAiConversation
      tags: [voice-ai]
      summary: Supervisor takeover of a live AI conversation
      description: |
        Transfers the debtor leg from the AI to the supervisor's REGISTERED endpoint
        and tears down the orphaned AI leg — the SAME evidence-first mechanic as
        `POST /v1/supervision/calls/{call_id}/takeover` (a durable `supervision_actions`
        row is written BEFORE any switch command). The `{id}` is the CONVERSATION id;
        the server resolves its `call_id` and drives `Dialer.Supervision`. A
        conversation with no live call (sandbox/never-dialed) is `409`. `session_id` in
        the result is `null` (no eavesdrop leg). Same WHO gate as the supervision
        operations (ADR #116): `supervisor_ext` must carry role `supervisor|admin`,
        else `403` `role_forbidden` (evidenced as `refused`).
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SupervisionRequest" }
            example:
              supervisor_ext: "2000"
      responses:
        "200":
          description: Debtor leg transferred to the supervisor (audit row durable).
          content:
            application/json:
              schema:
                type: object
                required: [supervision]
                properties:
                  supervision: { $ref: "#/components/schemas/SupervisionResult" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/SupervisionRoleForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "502": { $ref: "#/components/responses/SupervisionFailed" }
        "503": { $ref: "#/components/responses/SupervisionUnavailable" }

  /v1/ai/pause:
    post:
      operationId: pauseAiDialing
      tags: [voice-ai]
      summary: Kill-switch — pause all AI dialing (tenant-wide)
      description: |
        Idempotent. Immediately stops the tenant's AI agents from originating new calls
        (the "Pause all AI dialing" control; the AIDisclosure gate then blocks every AI
        origination fail-closed). In-flight conversations continue; use takeover to
        intervene. The toggle is stamped (`paused_at`, `paused_by`). Management scope: a
        `ddw_` browser token is `403`.
      responses:
        "200":
          description: AI dialing is paused.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AiPauseState" }
              example: { ai_dialing: paused, paused_at: "2026-07-09T17:40:00Z" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/ai/resume:
    post:
      operationId: resumeAiDialing
      tags: [voice-ai]
      summary: Kill-switch — resume AI dialing (tenant-wide)
      description: Idempotent inverse of `POST /v1/ai/pause`. Management scope (`403` for `ddw_`).
      responses:
        "200":
          description: AI dialing is active.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AiPauseState" }
              example: { ai_dialing: active, paused_at: null }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/ai/status:
    get:
      operationId: getAiStatus
      tags: [voice-ai]
      summary: AI-dialing kill-switch state
      description: The current kill-switch state (same shape as pause/resume).
      responses:
        "200":
          description: The kill-switch state.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AiPauseState" }
              example: { ai_dialing: active, paused_at: null }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/ai/metrics/today:
    get:
      operationId: getAiMetricsToday
      tags: [voice-ai]
      summary: Today's AI operating metrics (UTC day)
      description: |
        Aggregate for the current UTC day off `ai_conversations` + outcomes + the
        durable `ai_usage` meter (the same billable feed as `/v1/stats/ai-usage`).
      responses:
        "200":
          description: Today's metrics.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AiMetricsToday" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/ai/eligibility:
    get:
      operationId: getAiEligibility
      tags: [voice-ai]
      summary: Per-campaign artificial-voice eligibility
      description: |
        One entry per campaign that PLAYS artificial voice (AI or TTS/IVR/survey), each
        with whether it may originate now given the tenant's AI-disclosure capability
        and the AI-dialing kill-switch. Per-recipient consent (the other half of the
        artificial-voice gate) is enforced at dial time — not summarized here.
      responses:
        "200":
          description: The eligibility list.
          content:
            application/json:
              schema:
                type: object
                required: [eligibility]
                properties:
                  eligibility:
                    type: array
                    items: { $ref: "#/components/schemas/AiEligibilityItem" }
        "401": { $ref: "#/components/responses/Unauthorized" }
# ── public SMS provider webhooks (incoming; NO bearer auth) ───────────────
# The only unauthenticated POST surface of the control plane, mounted BEFORE
# the /v1 forward. Auth is a per-provider signature over the RAW body; the
# registry row's `adapter` names the scheme. Modeled as OpenAPI webhooks:
# these are requests the PROVIDER makes to the platform.
  /v1/recordings:
    get:
      operationId: listRecordings
      tags: [recordings]
      summary: List call recordings (filtered, keyset-paginated)
      description: |
        Recording metadata for the tenant's calls, newest first. The AUDIO is never
        served here — fetch a short-lived signed URL via
        `POST /v1/recordings/{id}/playback-url`. `q` is a free-text match over the
        masked phone number, `account_ref` and any external reference. `from`/`to`
        bound `started_at`. Foreign/malformed filter ids are indistinguishable from
        "no match" (RLS), never a leak.
      parameters:
        - $ref: "#/components/parameters/FromFilter"
        - $ref: "#/components/parameters/ToFilter"
        - $ref: "#/components/parameters/AgentIdFilter"
        - $ref: "#/components/parameters/CampaignIdFilter"
        - $ref: "#/components/parameters/DispositionFilter"
        - $ref: "#/components/parameters/QParam"
        - $ref: "#/components/parameters/LimitParam"
        - $ref: "#/components/parameters/CursorParam"
      responses:
        "200":
          description: A page of recordings.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RecordingPage" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  # NOTE: exports are a TOP-LEVEL resource (`/v1/recording-exports`), NOT nested
  # under `/v1/recordings/…`. A nested `/recordings/exports/{job_id}` is ambiguous
  # with `/recordings/{id}/playback-url` (literal-vs-param at crossed positions) —
  # Redocly's `no-ambiguous-paths` rule flags it and the live spec avoids it
  # (zero warnings). Semantics are unchanged from the baseline; only the path spells
  # differently. It sits beside the existing `/v1/recording-policy` sibling.
  /v1/recording-exports:
    post:
      operationId: createRecordingExport
      tags: [recordings]
      summary: Queue a bulk recording export
      description: |
        Enqueues an asynchronous export of the recordings matching the same filters
        as `GET /v1/recordings`. Returns `202` with a `job_id`; poll
        `GET /v1/recording-exports/{job_id}` for the signed archive URL. The export
        job row is durable evidence of who pulled bulk recordings and is never
        deleted.

        An explicit `from`/`to` range wider than the platform limit
        (`recording_export_max_range_days`, default 366 days) — or a far-past
        `from` with no upper bound — is rejected with `422 range_too_large`. An
        unbounded request (no `from`) is still accepted and bounded downstream by
        the 50k-row export cap.

        Repeating an identical request inside the dedup window
        (`recording_export_dedup_ttl_seconds`, default 900) returns the SAME
        completed job — with a freshly-signed URL — instead of regenerating the
        archive, so a retry or double-click answers instantly. Send
        `refresh: true` to force a new export. The window is short on purpose:
        recording media uploads land asynchronously, so a long-lived cache of a
        past window could omit a late-arriving recording.

        One GENERATING export per tenant at a time: while an export is being
        produced, a further generating request for that tenant is rejected with
        `429 export_in_progress`. Retrying once the in-flight export lands is
        cheap — an identical repeat then takes the dedup path above and answers
        from the archive it produced. Handing back an already-produced archive is
        never gated.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RecordingExportRequest" }
            example:
              from: "2026-06-01T00:00:00Z"
              to: "2026-06-30T23:59:59Z"
              campaign_id: 3f2c1b0a-9d8e-4c7b-a6f5-4e3d2c1b0a99
      responses:
        "202":
          description: Export accepted and queued.
          content:
            application/json:
              schema:
                type: object
                required: [export]
                properties:
                  export: { $ref: "#/components/schemas/RecordingExportJob" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422":
          description: |
            The requested export range exceeds the platform limit
            (`recording_export_max_range_days`, default 366 days). Narrow the
            `from`/`to` window and retry.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
              example:
                error: { code: range_too_large, message: "requested range exceeds the 366-day export limit" }
        "429":
          description: |
            This tenant already has an export being generated. Only one
            generating export runs per tenant at a time; retry once it completes
            (an identical repeat is then served from the produced archive without
            regenerating). A claim whose request died is released automatically
            after `recording_export_claim_stale_seconds` (default 900).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
              example:
                error: { code: export_in_progress, message: "an export is already running for this tenant; retry when it completes" }
        "503":
          description: |
            The recording export object store is not configured. The metadata
            export does not require the S3 audio store and degrades to the local
            store when it is unset; a `503` is returned only when no store at all
            can accept the archive (never a `500`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
              example:
                error: { code: storage_unavailable, message: "recording export storage is not configured" }

  /v1/recording-exports/{job_id}:
    get:
      operationId: getRecordingExport
      tags: [recordings]
      summary: Poll a recording export job
      description: |
        Returns the job status; when `done`, `url` is a short-lived signed archive
        URL (`expires_at` marks its expiry). Cross-tenant/unknown `job_id` → `404`.
      parameters:
        - name: job_id
          in: path
          required: true
          description: Export-job UUID. Malformed or cross-tenant ids read as `404`.
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: The export job.
          content:
            application/json:
              schema:
                type: object
                required: [export]
                properties:
                  export: { $ref: "#/components/schemas/RecordingExportJob" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/recordings/{id}:
    get:
      operationId: getRecording
      tags: [recordings]
      summary: Fetch full recording metadata
      description: |
        The complete evidence view: consent basis, the frozen `gates_at_dial`
        snapshot, the citable `decision_ref` into the audit chain, object-store
        metadata and the legal-hold record. No audio bytes.
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: The recording.
          content:
            application/json:
              schema:
                type: object
                required: [recording]
                properties:
                  recording: { $ref: "#/components/schemas/Recording" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/recordings/{id}/playback-url:
    post:
      operationId: mintRecordingPlaybackUrl
      tags: [recordings]
      summary: Mint a short-lived signed playback URL
      description: |
        Returns a short-lived signed object-store URL (Cloudflare R2 / S3-compatible)
        the browser fetches directly — the audio NEVER transits the API server.
        `expires_at` is the URL's hard expiry (minutes). Each mint is audited.
      responses:
        "200":
          description: A freshly signed URL.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RecordingPlaybackUrl" }
              example:
                url: "https://r2.example.com/rec/0e9f8a7b...?X-Amz-Signature=..."
                expires_at: "2026-07-09T18:05:00Z"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
      parameters:
        - $ref: "#/components/parameters/PathId"

  /v1/recordings/{id}/legal-hold:
    put:
      operationId: setRecordingLegalHold
      tags: [recordings]
      summary: Set or lift the legal hold on a recording
      description: |
        Idempotent full-set of the recording's legal-hold sub-resource. `active: true`
        REQUIRES a non-empty `reason`. A held recording is exempt from every purge
        until the hold is lifted, even past its retention floor. Tenant-management
        scope: a `dd_` tenant API key only — a `ddw_` browser token is `403`. Setting
        the hold is audited (`set_by`, `set_at`).
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RecordingLegalHoldWrite" }
            example:
              active: true
              reason: "Litigation hold — Apex Recovery v. Doe, matter #2026-0417"
      responses:
        "200":
          description: The recording with the updated hold.
          content:
            application/json:
              schema:
                type: object
                required: [recording]
                properties:
                  recording: { $ref: "#/components/schemas/Recording" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/recording-policy:
    get:
      operationId: getRecordingPolicy
      tags: [recordings]
      summary: Fetch the tenant recording policy
      responses:
        "200":
          description: The effective policy (defaults materialised on first read).
          content:
            application/json:
              schema:
                type: object
                required: [recording_policy]
                properties:
                  recording_policy: { $ref: "#/components/schemas/RecordingPolicy" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    put:
      operationId: setRecordingPolicy
      tags: [recordings]
      summary: Replace the tenant recording policy
      description: |
        Full-set of the singleton per-tenant policy. `retention_months` is clamped to
        the regulatory FLOOR (39) — a lower value is `422 unprocessable`. Changing the
        policy NEVER re-dates existing recordings: `retained_until` is frozen per row
        at creation (snapshot-at-the-moment doctrine). Tenant-management scope
        (`dd_` only; `ddw_` → `403`).
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RecordingPolicyWrite" }
            example:
              enabled: true
              retention_months: 39
              channels: stereo
      responses:
        "200":
          description: The updated policy.
          content:
            application/json:
              schema:
                type: object
                required: [recording_policy]
                properties:
                  recording_policy: { $ref: "#/components/schemas/RecordingPolicy" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/Unprocessable" }

  /v1/artificial-voice-disclosure:
    get:
      operationId: getArtificialVoiceDisclosure
      tags: [compliance]
      summary: Fetch the tenant NON-AI artificial-voice disclosure
      description: |
        The stored disclosure for the NON-AI artificial-voice lane (TTS / IVR /
        survey). A tenant with nothing stored is `404 not_found` — NOT a `200`
        with a null body: "no disclosure stored" is a real compliance state, and
        a client branching on the status code must not read it as configured.
      responses:
        "200":
          description: The stored disclosure.
          content:
            application/json:
              schema:
                type: object
                required: [artificial_voice_disclosure]
                properties:
                  artificial_voice_disclosure:
                    { $ref: "#/components/schemas/ArtificialVoiceDisclosure" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    put:
      operationId: setArtificialVoiceDisclosure
      tags: [compliance]
      summary: Store the tenant NON-AI artificial-voice disclosure
      description: |
        Upsert on the tenant (one row, last write wins). The TEXT is judged by the
        SAME content check the AI lane applies: it must clearly identify the voice
        as automated, in English or Spanish. A text that does not is
        `400 bad_request` and NOTHING is stored — a gate cannot be handed a `true`
        the text has not earned.

        `source` and `actor` are provenance and are set by the SERVER (`api` plus
        the acting principal); sending either is `400 bad_request` naming the key.
        Tenant-management scope (`dd_` API key or dashboard login; `ddw_` → `403`).
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ArtificialVoiceDisclosureWrite" }
            example:
              text: >-
                Hello, this is an automated call from Acme Collections.
      responses:
        "200":
          description: The stored disclosure.
          content:
            application/json:
              schema:
                type: object
                required: [artificial_voice_disclosure]
                properties:
                  artificial_voice_disclosure:
                    { $ref: "#/components/schemas/ArtificialVoiceDisclosure" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
  # ── outbound webhooks (MT-CTI-04 phase 1, ADR #121) ─────────────────────
  /v1/webhooks:
    get:
      operationId: listWebhooks
      tags: [webhooks]
      summary: Outbound webhook endpoints
      description: |
        Every endpoint of the tenant, enabled or not, oldest first. Responses carry
        `secret_hint` (the last 4 characters of the CURRENT signing secret) and
        NEVER the secret. Tenant scope (`dd_` key or a dashboard login); a `ddw_`
        widget token is **403**.
      responses:
        "200":
          description: The tenant's endpoints.
          content:
            application/json:
              schema:
                type: object
                required: [webhooks]
                properties:
                  webhooks:
                    type: array
                    items: { $ref: "#/components/schemas/WebhookEndpoint" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      operationId: createWebhook
      tags: [webhooks]
      summary: Register an outbound webhook endpoint
      description: |
        Mints the endpoint's HMAC signing secret and returns it **once**, as the
        top-level `secret` of this response — it is never readable again (reads
        show `secret_hint`; `POST /v1/webhooks/{id}/rotate-secret` mints a new one).
        The secret is stored envelope-encrypted per tenant.

        `url` MUST be an `https://` URL **on port 443** whose host resolves only to
        PUBLIC addresses — an IP literal, loopback, RFC1918, CGNAT, link-local /
        cloud metadata, `.internal`/cluster names and a name that does not resolve
        are refused **400** naming `url` (SSRF guard, fail-closed); the same check
        re-runs at delivery time. `events` is a non-empty subset of the closed v1
        catalog (`call.ended`, `call.answered`, `promise.recorded`, `sms.received`,
        `sms.optout`, `recording.available`); an unknown or repeated event is **400**.
        `recording.available` is accepted now and emitted once its producer exists.

        Nothing is delivered yet: the outbox and the dispatcher are the next
        phases of MT-CTI-04. `tenant_id` and `secret` are NOT request fields — a
        body carrying either is **400** naming it.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url, events]
              properties:
                url:
                  type: string
                  maxLength: 2048
                  description: "`https://` on 443, public host only (see above)."
                events:
                  type: array
                  minItems: 1
                  uniqueItems: true
                  items: { $ref: "#/components/schemas/WebhookEventName" }
              additionalProperties: false
            example:
              url: https://crm.example.com/hooks/dialerdigital
              events: [call.ended, promise.recorded]
      responses:
        "201":
          description: The endpoint, plus the signing secret — the FIRST of the only two times it is shown.
          content:
            application/json:
              schema:
                type: object
                required: [webhook, secret]
                properties:
                  webhook: { $ref: "#/components/schemas/WebhookEndpoint" }
                  secret:
                    type: string
                    description: "`whsec_`-prefixed HMAC secret. Shown ONCE; store it now."
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
  /v1/webhooks/{id}:
    get:
      operationId: getWebhook
      tags: [webhooks]
      summary: Fetch one endpoint
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: The endpoint (`secret_hint`, never the secret).
          content:
            application/json:
              schema:
                type: object
                required: [webhook]
                properties:
                  webhook: { $ref: "#/components/schemas/WebhookEndpoint" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      operationId: updateWebhook
      tags: [webhooks]
      summary: Update url / events / enabled
      description: |
        `url` and `events` re-run the same validation as the create. `enabled: true`
        re-enables an endpoint that `DELETE` disabled and clears `disabled_reason`.
        The secret is NOT a field here — rotation is its own verb, so no write can
        replace a secret without handing the new one back. Unknown keys are **400**.
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                url: { type: string, maxLength: 2048 }
                events:
                  type: array
                  minItems: 1
                  uniqueItems: true
                  items: { $ref: "#/components/schemas/WebhookEventName" }
                enabled: { type: boolean }
              additionalProperties: false
            example:
              events: [call.ended, call.answered, sms.received]
      responses:
        "200":
          description: The updated endpoint.
          content:
            application/json:
              schema:
                type: object
                required: [webhook]
                properties:
                  webhook: { $ref: "#/components/schemas/WebhookEndpoint" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      operationId: disableWebhook
      tags: [webhooks]
      summary: Disable an endpoint (rows are never erased)
      description: |
        Sets `enabled: false` with `disabled_reason: "disabled_by_api"` and keeps
        the row: it is the tenant's evidence of what was subscribed and when, and
        the app role has no DELETE grant. Idempotent — a second DELETE keeps the
        original reason. `PATCH {"enabled": true}` brings it back.
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: The endpoint, now disabled.
          content:
            application/json:
              schema:
                type: object
                required: [webhook]
                properties:
                  webhook: { $ref: "#/components/schemas/WebhookEndpoint" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /v1/webhooks/{id}/rotate-secret:
    post:
      operationId: rotateWebhookSecret
      tags: [webhooks]
      summary: Rotate the signing secret (24 h overlap window)
      description: |
        Mints a new secret and returns it **once** (top-level `secret`). The
        previous secret keeps verifying until `previous_secret_expires_at` (24 h):
        deliveries in that window are signed with the new secret and carry a second
        signature with the previous one, so a receiver that has not switched yet
        keeps verifying. Only the last two secrets ever verify — a rotation inside
        an open window replaces the previous one. No request body.
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: The endpoint (new `secret_hint`, window open) plus the NEW secret — the second and last time a secret is shown.
          content:
            application/json:
              schema:
                type: object
                required: [webhook, secret]
                properties:
                  webhook: { $ref: "#/components/schemas/WebhookEndpoint" }
                  secret:
                    type: string
                    description: The NEW `whsec_` secret. Shown ONCE.
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /v1/webhooks/{id}/deliveries:
    get:
      operationId: listWebhookDeliveries
      tags: [webhooks]
      summary: Deliveries of one endpoint, with every attempt
      description: |
        The append-only evidence of what this endpoint was sent and what came
        back — newest first, keyset-paginated. A tenant disputing a CRM ("we
        never got it") answers with this: each delivery carries its `attempts`
        in order, and each attempt the HTTP `status_code`, the first ≤ 1 KiB of
        the response BODY (`response_excerpt`, never headers) and the instants
        the request started and finished.

        `status` is DERIVED from the attempts, not stored: `pending` when no
        attempt was made yet, otherwise the `outcome` of the LAST one
        (`retry`, `delivered`, `deadletter`, `endpoint_disabled`). The event
        envelope is NOT part of a row — `data` is the public `/v1` projection
        of the resource and is readable from the resource's own `GET`.

        Filters: `event` (one name of the v1 catalog), `status` (one of the
        five above), `since` (RFC-3339, inclusive lower bound on the
        delivery's `created_at`). An unknown value for `event` or `status` is
        **400** naming the accepted set — never a silently empty page. Tenant
        scope (`dd_` key or a dashboard login); a `ddw_` widget token is
        **403**, and an endpoint of another tenant is **404**.
      parameters:
        - $ref: "#/components/parameters/PathId"
        - name: event
          in: query
          required: false
          schema: { $ref: "#/components/schemas/WebhookEventName" }
        - name: status
          in: query
          required: false
          schema: { $ref: "#/components/schemas/WebhookDeliveryStatus" }
        - name: since
          in: query
          required: false
          schema: { type: string, format: date-time }
          description: Only deliveries created at or after this instant.
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
        - name: cursor
          in: query
          required: false
          schema: { type: string }
          description: Opaque keyset cursor — the `next_cursor` of the previous page.
      responses:
        "200":
          description: One page of deliveries, newest first.
          content:
            application/json:
              schema:
                type: object
                required: [deliveries, next_cursor]
                properties:
                  deliveries:
                    type: array
                    items: { $ref: "#/components/schemas/WebhookDelivery" }
                  next_cursor:
                    oneOf: [{ type: string }, { type: "null" }]
                    description: Cursor of the next page; `null` on the last one.
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /v1/webhooks/{id}/deliveries/{delivery_id}/redeliver:
    post:
      operationId: redeliverWebhookDelivery
      tags: [webhooks]
      summary: Queue one more attempt for a delivery
      description: |
        APPENDS a `retry` attempt due now (`reason: "manual_redelivery"`) to the
        delivery's stream; the dispatcher's next tick sends it. Nothing already
        recorded is edited — the attempts are append-only in the database
        itself, so the evidence a dispute rests on cannot be rewritten, not
        even by the platform.

        **202**, not 200: the send happens on the next dispatcher tick, and its
        result appears as the NEXT attempt in
        `GET /v1/webhooks/{id}/deliveries`. The response carries the attempt
        that was queued.

        The numbering continues, so a delivery that already exhausted the retry
        ladder gets exactly ONE more send: a failure after that is recorded as
        `deadletter`, not as another `retry`.

        A **disabled** endpoint is **409** naming why it was disabled — the
        `410 Gone` its receiver answered (`endpoint_gone_410`) or the API call
        (`disabled_by_api`); re-enable it with `PATCH {"enabled": true}` first.
        **409** as well if another attempt was appended concurrently (two
        redeliveries racing, or one racing the dispatcher): read the delivery
        back before asking again. No request body.
      parameters:
        - $ref: "#/components/parameters/PathId"
        - name: delivery_id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        "202":
          description: The attempt queued for the dispatcher's next tick.
          content:
            application/json:
              schema:
                type: object
                required: [delivery_id, status, attempt]
                properties:
                  delivery_id: { type: string, format: uuid }
                  status:
                    type: string
                    enum: [retry]
                    description: The delivery's derived status after the append.
                  attempt: { $ref: "#/components/schemas/WebhookDeliveryAttempt" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
webhooks:
  smsDeliveryReceipt:
    post:
      operationId: webhookSmsDlr
      tags: [sms]
      summary: "Delivery receipt — POST /webhooks/sms/{provider_id}/dlr"
      description: |
        Served at `POST /webhooks/sms/{provider_id}/dlr` where `{provider_id}` is a
        row of `/v1/sms/providers`. NO bearer auth — per-provider signature over the
        RAW request body, verified in constant time BEFORE any JSON decode:

        - Internal contract (`http`/`mock` adapters): header `x-dd-signature` =
          HMAC-SHA256-hex(webhook_secret, raw_body).
        - `telnyx` adapter: Ed25519 over `"{telnyx-timestamp}|{raw_body}"` against
          the row's public key, headers `telnyx-signature-ed25519` +
          `telnyx-timestamp`, ±5 min replay window; payloads are the Telnyx v2
          envelope normalized by the adapter.

        Unknown/inactive/non-uuid provider id → `404` (no existence leak);
        bad/missing signature or no configured secret → `401` with ZERO table
        touches. Idempotent: DLR replays collapse (`200 {"status": "duplicate"}`);
        events that verify but cannot be attributed answer `200 {"status":
        "ignored"}`. A ledger write failure answers `500` so the provider retries.
        Bodies above 1 MB → `413`.
      security: []
      parameters:
        - name: x-dd-signature
          in: header
          description: Internal contract — HMAC-SHA256 hex over the raw body.
          schema: { type: string }
        - name: telnyx-signature-ed25519
          in: header
          description: Telnyx — Ed25519 signature (base64).
          schema: { type: string }
        - name: telnyx-timestamp
          in: header
          description: Telnyx — unix timestamp signed with the body (±5 min window).
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [message_id, status]
              properties:
                message_id: { type: string, description: Provider message id from the send ACCEPT. }
                status: { type: string, enum: [sent, delivered, undelivered, failed] }
            example:
              message_id: mock-0001
              status: delivered
      responses:
        "200":
          description: Recorded as a compensating status row (or collapsed replay / unattributable event).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookAck" }
              examples:
                ok: { value: { status: ok } }
                duplicate: { value: { status: duplicate } }
                ignored: { value: { status: ignored } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
  smsInbound:
    post:
      operationId: webhookSmsInbound
      tags: [sms]
      summary: "Mobile-originated message — POST /webhooks/sms/{provider_id}/inbound"
      description: |
        Served at `POST /webhooks/sms/{provider_id}/inbound`. Same signature model
        as the DLR webhook (see `smsDeliveryReceipt`). Keyword handling (normalized,
        EN+ES): `STOP`/`ALTO`/`UNSUBSCRIBE`/`CANCEL`/`QUIT`/`END` → internal-DNC
        listing (the same durable store the gates read — the next send blocks) +
        consent revocation + canned confirmation reply; `HELP`/`AYUDA` → canned help
        reply; `START` → re-opt-in (consent re-granted for SMS, internal-DNC listing
        removed — regulatory lists are never touched); anything else → ledger row +
        masked `sms.received` live-floor frame. All idempotent; unattributable
        events answer `200 {"status": "ignored"}`.
      security: []
      parameters:
        - name: x-dd-signature
          in: header
          description: Internal contract — HMAC-SHA256 hex over the raw body.
          schema: { type: string }
        - name: telnyx-signature-ed25519
          in: header
          description: Telnyx — Ed25519 signature (base64).
          schema: { type: string }
        - name: telnyx-timestamp
          in: header
          description: Telnyx — unix timestamp signed with the body (±5 min window).
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [from, to, body, message_id]
              properties:
                from: { type: string, description: "Consumer's number, E.164." }
                to: { type: string, description: The tenant DID that received the message. }
                body: { type: string }
                message_id: { type: string }
            example:
              from: "+19995550123"
              to: "+13125550199"
              body: STOP
              message_id: mock-mo-0001
      responses:
        "200":
          description: Handled (keyword action, ledger row, replay collapse or ignore).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookAck" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
  crmEvent:
    post:
      operationId: webhookCrmEvent
      tags: [webhooks]
      summary: "Outbound — one v1 event POSTed to a registered webhook endpoint (MT-CTI-04)"
      description: |
        The platform is the CALLER here: the body is what a `/v1/webhooks` endpoint
        subscribed to the event receives. Phase 2 (core#960, ADR #122) defines the
        envelope and records one row per (endpoint, event) in the outbox
        `webhook_deliveries`, written in the SAME transaction as the domain fact;
        NOTHING is sent yet — the dispatcher (phase 3, core#961) drains the outbox,
        signs the body with the endpoint's secret (`X-DD-Signature`, plus
        `X-DD-Signature-Previous` during a rotation window) and retries with a lease.

        `data` is the public projection of the resource, byte-for-byte the shape
        its `GET` answers, serialized at the moment of the fact and never rebuilt:
        `call.ended` / `call.answered` → `CallAttempt` (the finalize correction row
        for `call.ended`; the original row with `answered_at` for `call.answered`),
        `promise.recorded` → `Promise`, `sms.received` / `sms.optout` → `SmsMessage`
        (list projection, 40-char `body_preview`), `recording.available` →
        `Recording`. `event_id` is the receiver's idempotency key: the same fact is
        never announced twice to the same endpoint.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WebhookEvent" }
            example:
              event: promise.recorded
              event_id: 019968d2-6b1e-7c4a-9b0e-3f2a1c5d7e90
              occurred_at: "2026-09-17T20:15:30.123456Z"
              tenant_id: 5a1c3e7d-2f4b-4c8e-9d1a-0b2c3d4e5f60
              data:
                id: 019968d2-6b1e-7c4a-9b0e-3f2a1c5d7e90
                tenant_id: 5a1c3e7d-2f4b-4c8e-9d1a-0b2c3d4e5f60
                debt_id: 019968d1-0000-7000-8000-000000000001
                call_attempt_id: null
                agent_id: null
                amount_cents: 12500
                currency: USD
                promised_date: "2026-09-27"
                status: pending
                note: null
                created_at: "2026-09-17T20:15:30.123456Z"
                updated_at: "2026-09-17T20:15:30.123456Z"
      responses:
        "2XX":
          description: Acknowledged. Any other status (or a timeout) is retried by the dispatcher (phase 3).
components:
  securitySchemes:
    tenantApiKey:
      type: http
      scheme: bearer
      bearerFormat: dd_ API key
      description: |
        Per-tenant API key (`Authorization: Bearer dd_...`). Only the sha256 of the
        token is stored (`api_keys` table, RLS-protected); the key IS the tenant —
        there is no `X-Tenant-ID`. Minted by onboarding, rotated via
        `/v1/api_keys`. `401` for missing/unknown/revoked keys; `403` when the
        tenant is suspended.
    adminApiToken:
      type: http
      scheme: bearer
      bearerFormat: platform-admin token
      description: |
        Platform-admin bearer (`ADMIN_API_TOKEN` deployment secret) for the
        `/v1/admin/*` surface — a SEPARATE trust domain that predates any tenant.
        Fail-closed: when the token is unset in the deployment, every `/v1/admin/*`
        request is `401`.
    widgetSessionToken:
      type: http
      scheme: bearer
      bearerFormat: ddw_ browser session token
      description: |
        Ephemeral, agent-scoped BROWSER token (`Authorization: Bearer ddw_...`,
        CTI-4). Minted server-to-server via `POST /v1/widget_tokens`; the embedded
        widget uses it for its own `renew`/`introspect` and the agent's call
        surface. A mandatory `Origin` header is value-matched against the
        configured widget-host origins (`WIDGET_HOST_ORIGINS`, a deploy constant
        — NOT the tenant's per-tenant embed allowlist). `401` for
        missing/unknown/revoked/expired tokens; `403` for a non-widget-host Origin.
    userSessionToken:
      type: http
      scheme: bearer
      bearerFormat: ddu_ login session token
      description: |
        Revocable, PERSON-scoped dashboard session (`Authorization: Bearer
        ddu_...`) minted by `POST /v1/auth/login` (email + password). Carries
        the SAME tenant scope as the `dd_` key — it is the human operator of
        the same console — plus the user identity for audit. Fixed expiry
        (12h default, 24h engine ceiling; no sliding renew), revoked by
        `POST /v1/auth/logout`, by password rotation (other sessions), or by
        disabling the user. Only the sha256 of the token is stored. `401` for
        missing/unknown/revoked/expired sessions; `403` when the tenant is
        suspended.
  parameters:
    PathId:
      name: id
      in: path
      required: true
      description: Resource UUID. Malformed or cross-tenant ids read as `404`.
      schema: { type: string, format: uuid }
    CallId:
      name: call_id
      in: path
      required: true
      description: The live call's FreeSWITCH uuid (= `call_attempts.call_uuid`, also the `call_id` of the `call.*` floor events).
      schema: { type: string, format: uuid }
    LimitParam:
      name: limit
      in: query
      description: Page size (default 100, max 1000).
      schema: { type: integer, default: 100, maximum: 1000, minimum: 1 }
    CursorParam:
      name: cursor
      in: query
      description: Opaque keyset cursor from the previous page's `next_cursor`.
      schema: { type: string }
    CampaignIdFilter:
      name: campaign_id
      in: query
      schema: { type: string, format: uuid }
    DebtIdFilter:
      name: debt_id
      in: query
      schema: { type: string, format: uuid }
    AgentIdFilter:
      name: agent_id
      in: query
      schema: { type: string, format: uuid }
    DispositionFilter:
      name: disposition
      in: query
      description: |
        Exact match against an OPEN vocabulary: `call_attempts.disposition` is written
        VERBATIM (`20260725000003_create_disposition_engine.exs`) and the seven engine
        values — `answered_human`, `abandoned`, `busy`, `no_answer`, `canceled`,
        `rejected`, `failed` — are a NAMED SUBSET of it, not its boundary: typed
        agent/AI outcomes (`promise_to_pay`, `wrong_number`) and platform markers
        (`not_placed`) are equally valid. An unknown code answers `200` with zero rows,
        never `400`. What IS `400` is a malformed SHAPE: the empty string, and the
        parameter repeated or sent in array form.
      schema: { type: string }
    FromFilter:
      name: from
      in: query
      description: RFC 3339 lower bound (endpoint-specific field; default trailing 30 UTC days where noted).
      schema: { type: string, format: date-time }
    ToFilter:
      name: to
      in: query
      description: RFC 3339 upper bound.
      schema: { type: string, format: date-time }
    CsvFormat:
      name: format
      in: query
      description: "`csv` for the downloadable artifact (equivalent: `Accept: text/csv`)."
      schema: { type: string, enum: [csv] }
    QParam:
      name: q
      in: query
      description: Free-text search over the masked phone number, `account_ref` and any external reference.
      schema: { type: string }
    AiAgentIdFilter:
      name: agent_id
      in: query
      description: Restrict to conversations handled by this AI agent.
      schema: { type: string, format: uuid }
  responses:
    BadRequest:
      description: Malformed request (shape/type errors, invalid filters, bad timestamps).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }
          example:
            error: { code: bad_request, message: from must be RFC-3339 }
    Unauthorized:
      description: Missing, unknown or revoked API key (or, on `/v1/admin/*`, a bad/unset admin token; on webhooks, a bad/missing signature).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }
          example:
            error: { code: unauthorized, message: invalid or revoked API key }
    Forbidden:
      description: The tenant is suspended.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }
          example:
            error: { code: forbidden, message: tenant is suspended }
    NotFound:
      description: |
        Unknown id, malformed (non-UUID) id OR another tenant's id — RLS returns
        zero rows, so all three are indistinguishable by design (no existence leak,
        never a 403 for foreign ids).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }
          example:
            error: { code: not_found, message: resource not found for this tenant }
    Conflict:
      description: Invalid state transition, uniqueness conflict, or a seat that is busy/reserved.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }
          example:
            error: { code: conflict, message: invalid state transition }
    Unprocessable:
      description: Shape is fine, semantics are not (broken domain rule).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }
          example:
            error: { code: unprocessable, message: scheduled_at must be at least 10 minutes out }
    ComplianceBlocked:
      description: |
        The compliance engine refused the operation (fail-closed). `gate` names the
        refusing gate; `decision_id` cites the audit-chain decision — attach it to
        any dispute. ZERO provider calls, ZERO ledger rows happened. Do NOT retry
        unchanged: the block is evidence, not a transient error. (This 422 is
        distinct from the generic `unprocessable` 422 and from
        `sms_provider_unconfigured`, which share the status code but differ in
        `code`.)
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ComplianceBlockedError" }
          example:
            error:
              code: compliance_blocked
              message: the compliance engine refused this send (dnc_listed)
              gate: dnc_listed
              decision_id: 2c1d0e9f-8a7b-6c5d-4e3f-2a1b0c9d8e7f
    SmsRateLimited:
      description: |
        Money/rate brakes, both retryable — but with different semantics. `code`
        `sms_spend_cap_exceeded`: month-to-date SMS spend is at the tenant cap;
        retry only after a cap raise or the month rollover. `code` `throttled`:
        rate brake; retry later with backoff. No `Retry-After` header is set.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }
          examples:
            spend_cap:
              value: { error: { code: sms_spend_cap_exceeded, message: month-to-date SMS spend is at the tenant cap } }
            throttled:
              value: { error: { code: throttled, message: SMS send throttled; retry later } }
    Throttled:
      description: Dial rate brake (retryable later with backoff). No `Retry-After` header is set.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }
          example:
            error: { code: throttled, message: dial throttled (rate) }
    CallControlThrottled:
      description: |
        The NR-18 request-rate brake refused this request (retryable next window).
        `code` `rate_limited`. Bounded per MOST SPECIFIC DURABLE principal
        (agent > user > tenant), never per tenant when a narrower one
        exists, so one hostile page cannot throttle its sibling agents. Durable
        means the budget survives re-authentication: logging back in mints a new
        token but does not buy a new budget. The four
        toggles share ONE budget; `hangup` is exempt and never answers `429`,
        because refusing a teardown would leave a call alive that its operator
        asked to end. Refused BEFORE the call is looked up, so this answer is
        identical whether or not the `call_id` exists. No `Retry-After` header.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }
          example:
            error: { code: rate_limited, message: call control rate limit exceeded; retry later }
    DispositionThrottled:
      description: |
        The NR-18 request-rate brake refused this request (retryable next window).
        `code` `rate_limited`. Bounded per most specific DURABLE principal
        (agent > user > tenant, surviving re-authentication), with a budget
        of its OWN and narrower than the control verbs': what it bounds is
        durable row growth, since every accepted disposition INSERTs a correction
        row into the append-only attempt history. Refused BEFORE the attempt is
        looked up, so this answer is identical whether or not the `id` exists. No
        `Retry-After` header.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }
          example:
            error: { code: rate_limited, message: disposition rate limit exceeded; retry later }
    WidgetRateLimited:
      description: |
        The widget-token write rate brake refused this request (retryable later).
        `code` `rate_limited`. mint is bounded per tenant; renew per token. No
        `Retry-After` header is set.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }
          example:
            error: { code: rate_limited, message: widget token mint rate limit exceeded; retry later }
    PasswordConfirmThrottled:
      description: |
        Too many FAILED current-password confirmations for this USER inside the
        fixed window (retryable later). `code` `rate_limited`. Keyed on the
        authenticated user — never on a network address, which a reverse proxy in
        front of core would collapse — and evaluated before the password is
        verified. A correct confirmation is not counted.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }
          example:
            error: { code: rate_limited, message: too many failed password confirmations; retry later }
    AdminAuthThrottled:
      description: |
        Too many FAILED platform-admin authentications from this origin inside the
        fixed window (retryable later). `code` `rate_limited`. Keyed on the calling
        origin, not on the token, and evaluated BEFORE the token is compared — so
        a `429` never reveals whether the presented token was the right one. A
        request carrying the correct token is not counted, so a healthy operator
        cannot reach this state.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }
          example:
            error: { code: rate_limited, message: too many failed admin authentications; retry later }
    LoginRateLimited:
      description: |
        The per-ACCOUNT login brake refused this attempt (fixed window; retryable
        later). `code` `rate_limited`. Keyed per email (`/v1/auth/login`) or per
        `{tenant_id, email}` (`/v1/auth/agent-login`), so it bounds many passwords
        against ONE account. Credential spraying across many accounts is currently
        COUNTED AND ALERTED but not refused, because the only per-source key
        available to core is collapsed by the dashboard's reverse proxy — a
        spraying attempt therefore still returns the ordinary `401`. No
        `Retry-After` header is set.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }
          example:
            error: { code: rate_limited, message: too many login attempts; retry later }
    SmsProviderError:
      description: |
        The SMS provider rejected the message: the send was queued (evidence-first)
        and is evidenced as a `failed` transition — never billed. `sms_message_id`
        points at the ledger row.
      content:
        application/json:
          schema:
            type: object
            required: [error]
            properties:
              error:
                type: object
                required: [code, message, sms_message_id]
                properties:
                  code: { type: string, const: provider_error }
                  message: { type: string }
                  sms_message_id: { type: string, format: uuid }
          example:
            error:
              code: provider_error
              message: the SMS provider rejected the message (:timeout)
              sms_message_id: 0e9f8a7b-6c5d-4e3f-2a1b-0c9d8e7f6a5b
    DialError:
      description: The dialer could not place the call (switch rejected the originate).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }
          example:
            error: { code: dial_error, message: the dialer could not place the call }
    SbcError:
      description: The SIP edge rejected the provisioning request.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }
          example:
            error: { code: sbc_error, message: the SIP edge rejected the provisioning request }
    CheckinUnavailable:
      description: |
        Checkin refused fail-closed AFTER the seat was opened, so the seat was
        reverted and nothing durable is left claiming the agent is available.
        Retry. TWO causes, told apart by `code` (`ErrorEnvelope` says to branch
        on it, and a client that only knows one of them will silently do nothing
        for the other):

        * `sbc_unavailable` — the SBC RPC failed. Credentials that cannot
          register are never returned.
        * `presence_unavailable` — the durable seat-presence write failed
          (core#557). It is not cosmetic: that column is what
          `SessionCapSweeper` reads to tell this session apart from the one it
          is tearing down, so a checkin that answered 200 without it hands out a
          live SIP credential over a seat the sweeper may take back.

        This response replaced the former `SbcUnavailable`, whose name promised
        the single cause it had when the checkin only had one.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }
          examples:
            sbc_unavailable:
              value:
                error: { code: sbc_unavailable, message: "softphone provisioning unavailable, retry" }
            presence_unavailable:
              value:
                error: { code: presence_unavailable, message: "seat presence could not be recorded, retry" }
    PlanInactive:
      description: |
        The tenant's plan was READ and is not effectively `active`, so the action
        is gated (`code` `plan_inactive`). On the SUPERVISION surface this status
        means the plan was actually read: a plan that could NOT be read passes
        instead of refusing (ADR #37). That is deliberately the opposite of the
        inbound gate's fail-CLOSED posture — a supervision leg goes to the
        tenant's own registrar and buys no carrier minutes, so the cost of a
        false refusal (a compliance officer blinded during a live call) exceeds
        the cost of a false admission.

        Note which verbs can return it: the three EAVESDROP modes only.
        `takeover` is exempt and never returns `plan_inactive`, under the rule
        "gate what ADDS exposure, never what CORRECTS it" — listen/whisper/barge
        each open a new leg, while takeover takes the call away and kills the
        agent leg. A plan lapsing mid-shift must not strand a live call the
        supervisor is trying to end. The exemption is narrow, not a billing
        loophole: a dark tenant still cannot open NEW supervision, it only keeps
        the lever to end properly what is already running.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }
          example:
            error: { code: plan_inactive, message: "tenant plan is not active; traffic is gated" }
    SupervisionQuotaFull:
      description: |
        The tenant's SUPERVISION quota is full (`code` `supervision_capacity`):
        `max_supervision_channels` concurrent supervision sessions are already
        open. A SEPARATE ceiling from `max_concurrent_channels` (ADR #37) and
        independent in BOTH directions — a saturated dialing floor never refuses
        supervision, and an exhausted supervision quota never refuses a dial.
        The unit is the (call, supervisor) session, so switching listen → barge
        on the same call costs no second slot. Retryable as soon as a supervised
        call ends. No `Retry-After` header is set.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }
          example:
            error:
              code: supervision_capacity
              message: tenant supervision quota is full; retry when a supervised call ends
    SupervisionRoleForbidden:
      description: |
        The caller may not supervise with that `supervisor_ext` (ADR #116, core#928).
        `code` `role_forbidden`: the extension resolves to an active agent whose
        `role` is not `supervisor` or `admin`. `code` `forbidden`: an agent (`ddw_`)
        principal named an extension that is not its own. Either way the attempt IS
        evidenced — a `supervision_actions` row with `result: refused` — and ZERO
        switch commands ran. Per tenant, `supervision_role_enforced = false` turns the
        role check off (the identity binding stays).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }
          examples:
            role_forbidden:
              value:
                error:
                  code: role_forbidden
                  message: supervisor_ext must belong to an agent with role supervisor or admin
            forbidden:
              value:
                error:
                  code: forbidden
                  message: supervisor_ext must be the agent bound to this token
    SupervisionFailed:
      description: The switch rejected the supervision command (call already ended / supervisor device not registered).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }
          example:
            error: { code: supervision_failed, message: the switch rejected the supervision command }
    SupervisionUnavailable:
      description: |
        `code` `audit_unavailable`: the evidence row could not be written so NO
        switch command was sent (evidence-first, fail-closed). `code`
        `esl_unavailable`: switch control link down. Both retryable.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }
          examples:
            audit_unavailable:
              value: { error: { code: audit_unavailable, message: "supervision audit unavailable, retry" } }
            esl_unavailable:
              value: { error: { code: esl_unavailable, message: "telephony control unavailable, retry" } }
    CallControlFailed:
      description: The switch rejected the control command (call already ended / leg gone). The audit row settles `failed`.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }
          example:
            error: { code: call_control_failed, message: the switch rejected the control command }
    CallControlUnavailable:
      description: |
        `code` `audit_unavailable`: the evidence row could not be written so NO
        switch command was sent (evidence-first, fail-closed). `code`
        `esl_unavailable`: switch control link down / timed out. Both retryable.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }
          examples:
            audit_unavailable:
              value: { error: { code: audit_unavailable, message: "call-control audit unavailable, retry" } }
            esl_unavailable:
              value: { error: { code: esl_unavailable, message: "telephony control unavailable, retry" } }
    MidCallComplianceBlocked:
      description: |
        The GATE_MID_CALL re-check refused the unhold: the consumer's consent
        was revoked, a written cease-and-desist is on file, or the frozen
        consumer timezone(s) are outside the quiet-hours window. The call STAYS
        on hold and an automatic hangup is scheduled (`auto_hangup_at`, C9) —
        a consumer is never kept on hold indefinitely past a compliance block.
        No `decision_id`: this is a cheap mid-call re-check, not an engine
        decision; the durable `call_control_actions` row is the evidence.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/MidCallComplianceBlockedError" }
          example:
            error:
              code: compliance_blocked
              message: the mid-call compliance re-check refused this unhold (quiet_hours)
              gate: mid_call
              reason_code: quiet_hours
              rule_id: fdcpa.quiet_hours.v1
              consumer_timezone: America/Chicago
              consumer_local_time: "2026-07-06T21:12:09-05:00"
              auto_hangup_at: "2026-07-07T02:17:09Z"
    SandboxUnavailable:
      description: The AI runtime sandbox is unconfigured or unreachable (fail-closed — the runtime may not exist yet).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }
          example:
            error: { code: sandbox_unavailable, message: "the AI runtime sandbox is not configured or unreachable" }
    BadGateway:
      description: An upstream dependency (the AI runtime sandbox) returned an unexpected/failed response.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }
          example:
            error: { code: bad_gateway, message: "the AI runtime sandbox could not be reached" }
  schemas:
    RecordingListItem:
      type: object
      description: The list-row projection (the dashboard Recordings table).
      required: [id, call_id, started_at, phone_number, ai_used, legal_hold]
      properties:
        id: { type: string, format: uuid }
        call_id:
          type: string
          description: The call's FreeSWITCH uuid (= `call_attempts.call_uuid`).
        started_at: { type: string, format: date-time, description: UTC. }
        local_tz:
          oneOf: [{ type: string }, { type: "null" }]
          description: The recipient's IANA zone (e.g. `America/Chicago`); the UI renders the local time from it.
        agent:
          oneOf:
            - type: object
              required: [id, name]
              properties:
                id: { type: string, format: uuid }
                name: { type: string }
            - type: "null"
          description: Denormalised for display; null for AI/inbound-unattended calls.
        account_ref:
          oneOf: [{ type: string }, { type: "null" }]
          description: Debt external reference / account number.
        phone_number: { type: string, description: The other party's E.164. }
        duration_secs:
          oneOf: [{ type: integer }, { type: "null" }]
        disposition:
          oneOf: [{ type: string }, { type: "null" }]
          description: The typification of the recorded call, DERIVED from its terminal CDR attempt (the latest compensating correction, or the original when none landed). null when the call has no resolvable CDR row.
        two_party_consent_state:
          oneOf: [{ type: string }, { type: "null" }]
          description: "Set when the recipient's state requires all-party consent; the value is the state code the flag was raised for (e.g. `WA`). null = not applicable."
        attest_a:
          type: boolean
          description: The outbound leg carried STIR/SHAKEN attestation A.
        ai_used: { type: boolean }
        retained_until:
          type: string
          format: date
          description: Retention floor date, frozen at creation (snapshot doctrine).
        legal_hold: { type: boolean }

    Recording:
      allOf:
        - $ref: "#/components/schemas/RecordingListItem"
        - type: object
          description: Full metadata (detail view).
          properties:
            campaign_id:
              oneOf: [{ type: string, format: uuid }, { type: "null" }]
            consent_basis:
              oneOf: [{ type: string }, { type: "null" }]
              description: "The consent this call ran under (e.g. `PEC`, `EBR`, `express_written`)."
            gates_at_dial:
              type: array
              description: The frozen pre-dial gate evaluation (evidence).
              items: { $ref: "#/components/schemas/GateResult" }
            decision_ref:
              oneOf: [{ type: string }, { type: "null" }]
              description: Citation into the existing audit chain (`decision_id`).
            storage: { $ref: "#/components/schemas/RecordingStorage" }
            legal_hold_detail: { $ref: "#/components/schemas/LegalHold" }
            audio_purged_at:
              oneOf: [{ type: string, format: date-time }, { type: "null" }]
              description: Non-null once the audio object was purged post-retention; the metadata row survives as evidence.
            created_at: { type: string, format: date-time }
            updated_at: { type: string, format: date-time }

    GateResult:
      type: object
      required: [gate, result]
      properties:
        gate: { $ref: "#/components/schemas/ComplianceGate" }
        result: { type: string, enum: [pass, fail, skip], description: The gate's verdict at dial time. }

    RecordingStorage:
      type: object
      properties:
        size_bytes:
          oneOf: [{ type: integer }, { type: "null" }]
        codec:
          oneOf: [{ type: string }, { type: "null" }]
          description: "e.g. `opus`, `pcm_s16le`."
        channels:
          type: string
          enum: [mono, stereo]
          description: stereo = dual-channel (agent / consumer split).

    LegalHold:
      type: object
      required: [active]
      properties:
        active: { type: boolean }
        reason:
          oneOf: [{ type: string }, { type: "null" }]
        set_by:
          oneOf: [{ type: string }, { type: "null" }]
          description: The principal that set the current hold state (agent id / api-key label).
        set_at:
          oneOf: [{ type: string, format: date-time }, { type: "null" }]

    RecordingPage:
      type: object
      required: [recordings, next_cursor]
      properties:
        recordings:
          type: array
          items: { $ref: "#/components/schemas/RecordingListItem" }
        next_cursor:
          oneOf: [{ type: string }, { type: "null" }]
          description: Opaque keyset cursor; `null` on the last page.

    RecordingPlaybackUrl:
      type: object
      required: [url, expires_at]
      properties:
        url: { type: string, description: Short-lived signed object-store URL. }
        expires_at: { type: string, format: date-time }

    RecordingLegalHoldWrite:
      type: object
      required: [active]
      properties:
        active: { type: boolean }
        reason:
          type: string
          description: REQUIRED (non-empty) when `active` is true; ignored when false.

    RecordingPolicy:
      type: object
      required: [enabled, retention_months, channels, format]
      properties:
        enabled: { type: boolean, description: Whether new calls are recorded by default. }
        retention_months:
          type: integer
          minimum: 39
          description: Retention floor in months (default 39, hard floor 39).
        channels:
          type: string
          enum: [mono, stereo]
          default: stereo
        format:
          type: string
          enum: [wav, opus]
          default: opus
          description: |
            Storage codec for NEW recordings. Never re-encodes what is already
            stored, and changing it never re-dates a retention: both are frozen
            per recording at creation. `opus` is the default a new tenant is
            born with (it divides the retained footprint by ~8); `wav` is the
            value to keep for a tenant under a regulatory, contractual or
            litigation obligation to hold uncompressed audio, and it carries a
            surcharge — see `RecordingPolicyWrite.format`. A tenant already on
            `wav` stays on `wav`: migration `20260907000100` first materialises a
            `wav` policy row for every tenant that exists on 2026-09-07, so the
            new default reaches only tenants created from that date on. Without
            that step it would also reach every older tenant that had never
            called this endpoint, because its row is created lazily.
        updated_at: { type: string, format: date-time }

    RecordingPolicyWrite:
      type: object
      description: |
        The full set of the singleton per-tenant policy. CLOSED: an unrecognized key
        is `400 bad_request` naming it, never a silent drop — a misspelled
        `retention_month` must not come back `200` with the previous retention still
        in force.
      additionalProperties: false
      properties:
        enabled: { type: boolean }
        retention_months:
          type: integer
          minimum: 39
          description: Below the floor (39) → `422 unprocessable`.
        channels: { type: string, enum: [mono, stereo] }
        format:
          type: string
          enum: [wav, opus]
          description: |
            Storage codec for NEW recordings; outside the vocabulary →
            `400 bad_request` naming the field (the 422 rung is reserved for the
            retention floor, which is a business-rule refusal).

            `opus` is the default and the cheap path. `wav` is the per-tenant
            exception in two cases and only two — a regulatory, contractual or
            litigation obligation to hold uncompressed audio, or the customer
            asking for it expressly — and **it will be billed**: the price is
            settled (`DialerDigital/macro-tasks#475`, 2026-09-06) at a flat
            5.00 USD per seat and month while a tenant's format is `wav`,
            counted at the last instant of the closed month, with no exemption
            for the tenant under a legal obligation (founder's codec decision
            in freeswitch `ADR.md` #15, 2026-09-04). The `wav_surcharge` line
            IS emitted: the monthly close bills it since
            `DialerDigital/core#779` (ADR #81), on the codec the tenant had at
            the period boundary — read from the append-only format history, not
            from the policy row as it stands today — and the same billable
            seats the `seats` line uses.

            Switching format affects only recordings made AFTER the change.
            Nothing stored is transcoded or re-dated: recordings sit under
            Object Lock with a 39-month retention floor frozen per row.

            This enum is mirrored by `Recordings.Policy.writable_formats/0` and
            may legitimately be NARROWER than the read side's: admitting a codec
            on READ is a catalog fact, minting one on WRITE is a product
            position. It was `[wav]` until `DialerDigital/core#731` and widened
            in one change with `writable_formats/0` and the column DEFAULT,
            because a default nobody can also SET is a half-open gate.

            The FreeSWITCH chart's `recording.format` is NOT this axis and does
            not choose the codec: it is per RELEASE while this is per TENANT,
            and its guard still rejects any value but `wav`
            (`deploy/chart/templates/_helpers.tpl`). What lands on disk is the
            extension of the object key core hands `uuid_record`
            (`Recordings.object_key/4`), derived from this field.

            HISTORY, so retired reasons are not resurrected. (a) Until
            `DialerDigital/freeswitch#85` this enum was justified by the upload
            sidecar resolving a SINGLE extension — a tenant on Opus would have
            FreeSWITCH write `.opus`, the sidecar find zero files, and nothing
            turn red. THAT IS FIXED: the sidecar discovers the whole handled set
            and quarantines anything outside it, naming it (freeswitch `ADR.md`
            #16). Deliberately no line numbers here: the previous text pointed at
            `uploader.sh:94`, which that very fix left blank. (b) Do NOT re-add
            "voice-ai cannot read non-WAV": measured and refuted
            (`DialerDigital/voice-ai#70`, closed not-planned 2026-09-04) —
            voice-ai consumes raw L16 over the media fork and never reads a
            stored recording, so the storage codec and what voice-ai ingests are
            independent variables.

    ArtificialVoiceDisclosure:
      type: object
      required: [text, source, actor, valid, updated_at]
      properties:
        text: { type: string, description: The stored disclosure text. }
        source:
          type: string
          enum: [api, import, migration]
          description: Provenance — how the row got here. Server-set, never taken from the body.
        actor:
          type: string
          description: |
            The principal that wrote it, narrowest-first: the acting agent id, else
            the dashboard user id when the caller is a `ddu_` session, else
            `tenant-api-key` for a `dd_` key that carries no finer identity.
            Server-set.
        valid:
          type: boolean
          description: |
            Whether the stored text STILL passes the content check. Re-computed on
            every read, not stored: the marker vocabulary can gain a term, and a row
            written under the older one must not keep vouching for a text the gate
            would no longer accept. This is the same question the dialing path asks.
        updated_at: { type: string, format: date-time }

    ArtificialVoiceDisclosureWrite:
      type: object
      description: |
        CLOSED: an unrecognized key is `400 bad_request` naming it. `source` and
        `actor` are deliberately NOT writable — a caller able to stamp its own
        write as `source: migration` by someone else turns a compliance record
        into an unsigned one.
      additionalProperties: false
      required: [text]
      properties:
        text:
          type: string
          minLength: 1
          maxLength: 10000
          x-maxLengthBytes: 10000
          description: |
            Must clearly identify the voice as automated (EN or ES). Anything else
            is `400 bad_request` and nothing is stored. The ceiling exists because
            the dialing path re-reads this row on every attempt.

            The EFFECTIVE ceiling is 10000 BYTES (UTF-8), enforced by the server
            and by a database CHECK. JSON Schema `maxLength` counts CHARACTERS and
            has no byte-length keyword, so the `maxLength: 10000` above is an UPPER
            bound, not the real limit: a multibyte text can be refused with
            `400 bad_request` well before reaching 10000 characters. The
            machine-readable form of the real limit is `x-maxLengthBytes`. See
            ADR #74.

    RecordingExportRequest:
      type: object
      description: Same filter shape as `GET /v1/recordings` (bounds the export set).
      properties:
        from: { type: string, format: date-time }
        to: { type: string, format: date-time }
        agent_id: { type: string, format: uuid }
        campaign_id: { type: string, format: uuid }
        disposition: { type: string }
        q: { type: string }
        refresh:
          type: boolean
          default: false
          description: |
            Bypass the short-lived dedup window and force a fresh export even if an
            identical one was just produced.

    RecordingExportJob:
      type: object
      required: [job_id, status]
      properties:
        job_id: { type: string, format: uuid }
        status:
          type: string
          enum: [queued, running, done, failed]
        url:
          oneOf: [{ type: string }, { type: "null" }]
          description: Signed archive URL; present only when `status` is `done`.
        expires_at:
          oneOf: [{ type: string, format: date-time }, { type: "null" }]
        row_count:
          oneOf: [{ type: integer }, { type: "null" }]
        error:
          oneOf: [{ type: string }, { type: "null" }]
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    ConfigEntry:
      type: object
      description: |
        One governed catalog key with the value its CONSUMER sees, where that
        value came from, and whether a per-tenant override applies to it at all.
      required:
        [key, value, source, type, default, sensitivity, consumer, override_honoured]
      properties:
        key: { type: string }
        value:
          description: |
            The value the code acting on this key actually sees. For a key whose
            `consumer` is `resolver` that is the resolved override chain; for a
            key read from application config it is the application-config value,
            reported RAW — a misconfigured deployment shows up here rather than
            being sanitised into a value nothing acts on. NEVER a stored override
            that the consumer does not read.
          oneOf:
            - { type: boolean }
            - { type: integer }
            - { type: string }
            - { type: "null" }
        source:
          type: string
          enum: [default, override, app_config]
          description: |
            `default` = the compile-time platform default; `override` = a stored
            per-tenant value that IS honoured; `app_config` = an explicitly set
            application-config value on the deployment.
        type:
          type: string
          enum: [boolean, integer]
        default:
          description: The platform default, which is also the fail-safe value.
          oneOf: [{ type: boolean }, { type: integer }]
        sensitivity:
          type: string
          enum: [compliance_loosening, money, security, operational]
          description: |
            Who may write this override. Everything except `operational` is
            admin-only forever: a tenant must never grant itself a laxer
            compliance posture or a higher spend ceiling.
        consumer:
          type: string
          description: |
            WHERE this key is really read. `resolver` = through the governed
            catalog, so a per-tenant override is honoured. `app_config:<key>` =
            the consumer reads that application-config key directly, so an
            override would be inert — and `<key>` is the exact name to set on the
            deployment instead.
          example: "app_config:spend_guard_enforce"
        override_honoured:
          type: boolean
          description: |
            Whether writing a per-tenant override for this key would change
            anything. `false` means `PUT` on this key is refused with `409`.
        ignored_override:
          description: |
            A row that EXISTS in the override table and is NOT reflected in
            `value` — residue to clean up with `DELETE`. Null when there is none.
          type: [object, "null"]
          required: [value, reason]
          properties:
            value:
              description: The stored value that is not being applied.
              oneOf:
                - { type: boolean }
                - { type: integer }
                - { type: string }
                - { type: "null" }
            reason:
              type: string
              enum: [consumer_not_migrated, invalid_value]
              description: |
                `consumer_not_migrated` — the consumer reads application config,
                so nothing reads this row. `invalid_value` — the stored value does
                not match the declared type, so resolution falls back to the
                default.
        doc: { type: string }
    ConfigAuditEntry:
      type: object
      description: One append-only config change.
      required: [key, active, changed_at]
      properties:
        key: { type: string }
        old_value:
          oneOf: [{ type: boolean }, { type: integer }, { type: "null" }]
        new_value:
          oneOf: [{ type: boolean }, { type: integer }, { type: "null" }]
        active:
          type: boolean
          description: "`false` = this change DEACTIVATED the override (back to the default)."
        actor:
          oneOf: [{ type: string }, { type: "null" }]
        changed_at: { type: string, format: date-time }
    DesignPartnerDiscount:
      type: object
      description: |
        One append-only design-partner discount grant (`core#220`): from
        `effective_from` through `effective_to` INCLUSIVE, the tenant's
        voice-AI subtotal is reduced by `percent` points. Rows are never edited
        or deleted; ending a grant early appends a `percent: 0` row with a
        later `effective_from`, and the superseded rows are the audit trail.
      required: [id, tenant_id, percent, effective_from, effective_to, granted_by, inserted_at]
      properties:
        id: { type: string, format: uuid }
        tenant_id: { type: string, format: uuid }
        percent:
          type: string
          description: Percentage points off the voice-AI subtotal, as a decimal string.
          example: "20.00"
        effective_from:
          type: string
          format: date
          description: The first day the grant applies.
        effective_to:
          type: string
          format: date
          description: The LAST day the grant applies (inclusive).
        granted_by:
          type: string
          description: Who granted it, and on what basis. Never machine-inferred.
        note: { type: string }
        inserted_at: { type: string, format: date-time }
    TaxNexusState:
      type: object
      description: |
        One append-only tax-nexus declaration (ADR #80 decision 3). A row says
        that on `effective_from` the company DOES (`active: true`) or NO LONGER
        DOES (`active: false`) have nexus in `country`/`region`. Rows are never
        edited or deleted; the superseded ones are the audit trail.
      required: [id, country, region, effective_from, active, declared_by, inserted_at]
      properties:
        id: { type: string, format: uuid }
        country:
          type: string
          description: ISO-3166 alpha-2, stored upcased.
          example: US
        region:
          type: string
          description: State/province code, stored upcased.
          example: CA
        effective_from:
          type: string
          format: date
          description: The day this declaration starts applying.
        active:
          type: boolean
          description: "`false` = nexus ENDED on `effective_from`."
        declared_by:
          type: string
          description: Who declared it, and on what basis. Never machine-inferred.
        note: { type: string }
        inserted_at: { type: string, format: date-time }
    ErrorEnvelope:
      type: object
      description: "The canonical error envelope. Branch on `code`: `bad_request`, `invalid`, `unauthorized`, `forbidden`, `not_found`, `conflict`, `unprocessable`, `unsupported_media_type`, `internal`, plus the endpoint-specific codes documented per response."
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code: { type: string, description: Stable machine-readable code. }
            message: { type: string, description: Human-readable; never branch on it. }
    ComplianceBlockedError:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message, gate, decision_id]
          properties:
            code: { type: string, const: compliance_blocked }
            message: { type: string }
            gate: { $ref: "#/components/schemas/ComplianceGate" }
            decision_id:
              type: string
              format: uuid
              description: Cites the audit-chain decision that refused the operation.
    MidCallComplianceBlockedError:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message, gate, reason_code]
          properties:
            code: { type: string, const: compliance_blocked }
            message: { type: string }
            gate:
              type: string
              const: mid_call
              description: The checkpoint (GATE_MID_CALL), not the refusing rule — that is `reason_code`.
            reason_code:
              type: string
              enum: [quiet_hours, consent_revoked, cease_and_desist]
            rule_id:
              type: string
              description: |
                The SAME canonical rule the pre-dial gate would cite
                (fdcpa.quiet_hours.v1 / regf.revocation.v1 /
                fdcpa.cease_and_desist.v1) — citable evidence without an
                engine decision_id.
            consumer_timezone:
              type: string
              description: The frozen zone that failed the window (quiet_hours only).
            consumer_local_time:
              type: string
              description: The consumer's local time at evaluation (quiet_hours only).
            auto_hangup_at:
              type: string
              format: date-time
              description: When the C9 watchdog will kill the consumer leg unless the hold is released first.
    ComplianceGate:
      type: string
      description: The compliance engine's reason-code vocabulary.
      enum:
        - tcpa_no_consent
        - quiet_hours
        - regf_7in7
        - regf_post_contact
        - state_limit
        - dnc_listed
        - consent_revoked
        - cease_and_desist
        - number_reassigned
        - tenant_policy
    TenantPlanEnvelope:
      type: object
      required: [plan]
      properties:
        plan:
          $ref: "#/components/schemas/TenantPlan"

    TenantPlan:
      type: object
      description: |
        The plan anchor (ADR #42). `status` is what PlanGate reads: only
        `active` lets the tenant place traffic.
      properties:
        scheme:
          type: string
          enum: [subscription, prepaid, postpaid]
        status:
          type: string
          enum:
            [draft, pending_approval, pending_deposit, pending_payment, active, suspended]
        tier_row_id:
          oneOf: [{ type: string, format: uuid }, { type: "null" }]
          description: "The SELLABLE tier version this plan committed to (subscription only)."
        current_period_start:
          oneOf: [{ type: string, format: date }, { type: "null" }]
        current_period_end:
          oneOf: [{ type: string, format: date }, { type: "null" }]
        period_price_usd:
          oneOf: [{ type: string }, { type: "null" }]
          description: "Decimal STRING (money is never a JSON float)."
        auto_renew: { type: boolean }
        enforcement_mode:
          type: string
          enum: [hard_block, graduated]
          description: |
            What a spend-threshold crossing does: `graduated` notifies the
            tenant, `hard_block` only records it. Neither posture changes
            WHETHER the tenant is blocked at 100% — that is unconditional.
        alert_thresholds:
          type: array
          description: "% points of the plan limit at which the tenant is warned."
          items: { type: integer, minimum: 1, maximum: 100 }
        alert_email:
          oneOf: [{ type: string, format: email }, { type: "null" }]
          description: |
            Where spend alerts are e-mailed. `null` ⇒ no e-mail destination
            (the emitter falls back to log-only), which is the state of every
            plan opened before this column existed.
        alert_webhook_url:
          oneOf: [{ type: string, format: uri }, { type: "null" }]
          description: |
            POST target for graduated-mode notifications. `null` ⇒ log-only.
            Admin surface only — it can carry a secret path token.

    Tenant:
      type: object
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        status: { type: string, enum: [active, suspended] }
        retention_months: { type: integer }
        seats: { type: integer }
        tier: { type: string, enum: [starter, growth, scale, enterprise, dialer_core, compliance_pro, audit_shield] }
        voice_ai_enabled: { type: boolean }
        ai_monthly_budget_usd:
          oneOf: [{ type: number }, { type: "null" }]
          description: Cost-integrity ceiling (admin-set; null = no cap).
        ai_max_call_seconds:
          oneOf: [{ type: integer }, { type: "null" }]
          description: >-
            Per-AI-call hard duration ceiling (seconds) set by the admin. `null` means
            the PLATFORM default applies — never "no ceiling" (core#922, ADR #115).
        ai_max_call_seconds_effective:
          type: integer
          minimum: 1
          description: >-
            The ceiling FreeSWITCH is actually handed for this tenant's AI legs:
            `ai_max_call_seconds` when set, else the cell's platform default
            (`AI_MAX_CALL_SECONDS_DEFAULT`, compiled fallback 900). Always present,
            never null. Read-only (derived).
        sms_monthly_budget_usd:
          oneOf: [{ type: number }, { type: "null" }]
          description: Month-to-date SMS spend cap.
        human_initiated_enabled:
          type: boolean
          description: |
            D17 human-initiated consent posture (admin-set; default `false`).
            When `true`, a LIVE preview/manual/CTI voice dial with no artificial
            and no AI voice does not require prior express consent (no ATDS).
            Every other gate — DNC, quiet hours, Reg F, state matrix,
            artificial voice — still applies unchanged.
        max_cps:
          oneOf: [{ type: integer, minimum: 1 }, { type: "null" }]
          description: Per-tenant CPS ceiling (admin-set; null = no ceiling). ADR #12.
        max_concurrent_channels:
          oneOf: [{ type: integer, minimum: 1 }, { type: "null" }]
          description: Per-tenant concurrent-channel ceiling (admin-set; null = no ceiling). ADR #12.
        abandon_seller_name:
          oneOf: [{ type: string }, { type: "null" }]
          description: >-
            Seller name for the FTC TSR abandoned-call identification message
            (admin-set; null = not configured, predictive over-dial stays dark). ADR #14.
        abandon_seller_phone:
          oneOf: [{ type: string }, { type: "null" }]
          description: >-
            E.164 callback number for the abandoned-call identification message
            (admin-set; null = not configured). ADR #14.
        tax_country:
          oneOf: [{ type: string, pattern: "^[A-Z]{2}$" }, { type: "null" }]
          description: >-
            ISO-3166-1 alpha-2 country of the client's sales-tax jurisdiction
            (admin-set; null = not declared). ADR #77.
        tax_region:
          oneOf: [{ type: string, minLength: 1, maxLength: 10 }, { type: "null" }]
          description: >-
            State/province code of the client's sales-tax jurisdiction
            (admin-set; null = not declared). ADR #77.
        tax_postal_code:
          oneOf: [{ type: string, minLength: 1, maxLength: 20 }, { type: "null" }]
          description: >-
            Postal code of the client's sales-tax jurisdiction (admin-set;
            null = not declared). ADR #77.
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    ApiKey:
      type: object
      description: Key metadata — the token hash is never exposed.
      properties:
        id: { type: string, format: uuid }
        label: { type: string }
        revoked_at:
          oneOf: [{ type: string, format: date-time }, { type: "null" }]
        last_used_at:
          oneOf: [{ type: string, format: date-time }, { type: "null" }]
          description: >-
            Rotation-hygiene clock: the last time this key authenticated
            SUCCESSFULLY. Written at most once a minute per key, so a fresh
            reading may lag by up to that much. `null` means it has not
            authenticated successfully since the column acquired a writer —
            not that the key was never presented.
        created_at: { type: string, format: date-time }
    User:
      type: object
      description: A dashboard user — the password hash is never exposed.
      properties:
        id: { type: string, format: uuid }
        email: { type: string, format: email, description: "Global login identifier, stored lowercase." }
        name: { type: string }
        status: { type: string, enum: [active, disabled] }
        role:
          type: string
          enum: [member, account_admin]
          description: >-
            Intra-account privilege. Only an `account_admin` may create or modify
            the empresa-wide rows every sibling sede inherits (`scope: "empresa"`
            on carriers and SMS providers); a `member` gets 403. Read-only here:
            promotion is an operator gesture, never a request.
        password_updated_at: { type: string, format: date-time }
        last_login_at:
          oneOf: [{ type: string, format: date-time }, { type: "null" }]
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    UserSession:
      type: object
      description: A ddu_ login session — the token hash is never exposed.
      properties:
        id: { type: string, format: uuid }
        user_id: { type: string, format: uuid }
        expires_at:
          type: string
          format: date-time
          description: "Fixed at login (no sliding renew); 24h engine ceiling."
        revoked_at:
          oneOf: [{ type: string, format: date-time }, { type: "null" }]
        created_at: { type: string, format: date-time }
    SipTeardown:
      type: string
      enum: [ok, skipped, failed]
      description: |
        Outcome of the SIP teardown that accompanies a revocation (NR-02): the
        deletion of the agent's ephemeral `agentcred` credential and its live
        `usrloc` binding in Kamailio.

        * `ok` — credential and binding removed; the softphone can no longer register.
        * `skipped` — nothing to remove: the agent has no SIP extension, SBC
          provisioning is not configured in this environment, or the agent still
          holds a VIGENT session (see below) so this revocation left no access
          open.
        * `failed` — the DB revocation stands, but the SIP credential is STILL
          LIVE and will remain so until its 24h autoexpire. **Treat as an open
          access path**: retry, or evict the agent by other means.

        It is a field and not an error status on purpose: the revocation itself
        is durable and did happen, so a `5xx` would be a lie in the other
        direction. What must never happen is a silent `200` implying an
        eviction that did not occur.

        **The teardown follows the TRANSITION, not the agent.** Revoking a token
        id that was already revoked — an id a client can legitimately hold, since
        `GET /v1/widget_tokens` lists revoked rows, and since minting supersedes
        the previous token — must NOT evict, because the agent's SUCCESSOR
        session would be the one cut. The teardown therefore runs only when the
        agent is left with no vigent token; otherwise it reports `skipped`.

        This is what keeps `failed`'s retry advice usable: an already-revoked
        token with no live successor still evicts on a retried `DELETE`, so
        retrying is a real remedy and not a no-op.
    WidgetToken:
      type: object
      description: A ddw_ browser widget token — the plaintext + hash are never exposed here.
      properties:
        id: { type: string, format: uuid }
        agent_id: { type: string, format: uuid }
        label: { type: string }
        allowed_origin_id:
          oneOf: [{ type: string, format: uuid }, { type: "null" }]
          description: Optional pin to one `widget_origins` row — narrows the EMBED origins surfaced to the frame (CSP/postMessage) to just that one; null = all the tenant's enabled embed origins. NOT the auth boundary (that is the widget-host Origin). Agent-login mints UNPINNED.
        expires_at: { type: string, format: date-time }
        session_expires_at: { type: string, format: date-time }
        revoked_at:
          oneOf: [{ type: string, format: date-time }, { type: "null" }]
        last_used_at:
          oneOf: [{ type: string, format: date-time }, { type: "null" }]
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    WidgetOrigin:
      type: object
      description: A per-tenant EMBED-allowlist web origin (canonical https form) — governs who may frame the widget (CSP `frame-ancestors` / postMessage), NOT the auth boundary (the widget-host Origin).
      properties:
        id: { type: string, format: uuid }
        origin: { type: string, description: "Canonical https origin (lowercase host, default :443 stripped)." }
        label: { type: string }
        disabled_at:
          oneOf: [{ type: string, format: date-time }, { type: "null" }]
          description: Non-null = disabled (dropped from the embed allowlist — no `frame-ancestors`/postMessage, never surfaced to a frame).
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    WidgetSession:
      type: object
      description: The introspection view a frame boots from (its own live session).
      required: [tenant_id, agent_id, origins, expires_at, session_expires_at]
      properties:
        tenant_id: { type: string, format: uuid }
        agent_id: { type: string, format: uuid }
        origins:
          type: array
          items: { type: string }
          description: The session's enabled EMBED origins (`widget_origins`) for the frame's CSP / postMessage — NOT the auth boundary (the widget-host Origin).
        expires_at: { type: string, format: date-time }
        session_expires_at: { type: string, format: date-time }
    Campaign:
      type: object
      properties:
        id: { type: string, format: uuid }
        tenant_id: { type: string, format: uuid }
        name: { type: string }
        # `suspended_balance` (R5) is written by the PLATFORM, never by a client:
        # the ledger crossed the tenant's suspension floor and took the campaign
        # off the air. It returns to `running` on its own when the balance
        # recovers -- no call, no operator. See `Dialer.Billing.BalanceSignal`.
        state: { type: string, enum: [draft, running, paused, completed, archived, suspended_balance] }
        campaign_type:
          type: string
          enum: [predictive, progressive, preview, manual, tts_blast, tts_ivr, ivr_cascade, survey, ai_voice, sms_blast]
        channel:
          type: string
          enum: [voice, sms]
          description: Derived from the type profile — which runner the lifecycle dispatches to.
        sms_body:
          oneOf: [{ type: string }, { type: "null" }]
        abandonment_threshold:
          oneOf: [{ type: string }, { type: "null" }]
          description: Decimal rendered as a JSON string.
        min_dial_ratio:
          oneOf: [{ type: string }, { type: "null" }]
        max_dial_ratio:
          oneOf: [{ type: string }, { type: "null" }]
        calling_window_start:
          oneOf: [{ type: string }, { type: "null" }]
          description: "`HH:MM:SS`."
        calling_window_end:
          oneOf: [{ type: string }, { type: "null" }]
        default_timezone:
          oneOf: [{ type: string }, { type: "null" }]
          description: >-
            IANA timezone. The zone the OPERATIONAL calling window is read in
            while `calling_window_destination_local` is `false`.
        calling_window_destination_local:
          type: boolean
          description: >-
            Read `calling_window_start`/`_end` in the DESTINATION's local hour
            instead of `default_timezone`. `false` by default, which is the
            behaviour every campaign had before this field existed. When `true`
            the window must be open in EVERY timezone the destination could be
            in, and a destination whose zone cannot be resolved does not open
            it. This is a tenant-defined bound only: it can narrow the LEGAL
            quiet-hours window, never widen it.
        record_calls: { type: boolean }
        ai_voice: { type: boolean }
        amd_enabled:
          oneOf: [{ type: boolean }, { type: "null" }]
          description: >-
            The STORED per-campaign Answering Machine Detection toggle. `null`
            = inherit the type profile's default (on for blaster/TTS/IVR/AI
            types, off for agent types).
        optout_digit:
          oneOf: [{ type: string }, { type: "null" }]
          description: The single DTMF key that opts the consumer out (`0`-`9`, `*`, `#`); `null` = no opt-out key.
        tts_prompt:
          oneOf: [{ type: string }, { type: "null" }]
          description: The operator's script for the `tts_*` treatments; `null` = no prompt.
        ivr_menu:
          type: object
          description: The IVR menu as written (`{}` = no menu). See `CampaignWrite.ivr_menu`.
        deferred_bridge: { type: boolean }
        consult_config:
          type: object
          description: The consult-line config as written (`{}` = disabled). See `CampaignWrite.consult_config`.
        carrier:
          oneOf: [{ type: string }, { type: "null" }]
        caller_ids:
          type: array
          items: { type: string }
          description: Prior round-robin pool of E.164 strings (used when `caller_id_pool` is empty).
        caller_id_pool:
          type: array
          items: { type: string, format: uuid }
          description: DID-registry ids; re-resolved every tick to the ACTIVE DIDs — an all-parked pool dials NOTHING (fail-closed).
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    CampaignWrite:
      type: object
      description: |
        Writable campaign fields (create requires `name`; `state` is never
        writable here). CLOSED: an unrecognized key is `400 bad_request`
        naming it, never a silent drop.
      additionalProperties: false
      properties:
        name: { type: string }
        campaign_type:
          type: string
          enum: [predictive, progressive, preview, manual, tts_blast, tts_ivr, ivr_cascade, survey, ai_voice, sms_blast]
          default: predictive
        sms_body:
          type: string
          description: REQUIRED for `sms_blast`; must contain the STOP opt-out notice; supports merge vars.
        abandonment_threshold: { type: number }
        min_dial_ratio: { type: number }
        max_dial_ratio: { type: number }
        calling_window_start: { type: string, description: "`HH:MM:SS`." }
        calling_window_end: { type: string }
        default_timezone: { type: string }
        calling_window_destination_local: { type: boolean }
        record_calls: { type: boolean }
        ai_voice: { type: boolean }
        amd_enabled:
          oneOf: [{ type: boolean }, { type: "null" }]
          description: >-
            Per-campaign Answering Machine Detection toggle; `null` (the
            default) inherits the type profile. Whether a detected machine
            gets a voicemail drop or a hang-up is still the compliance
            gates' decision, not this flag's.
        optout_digit:
          type: string
          pattern: "^[0-9*#]?$"
          description: >-
            Exactly ONE DTMF key: pressing it adds the number to the tenant
            DNC and ends the call. Anything else is `400 invalid` naming the
            field. `""` clears it.
        tts_prompt:
          type: string
          maxLength: 2000
          description: >-
            Script rendered to audio for the `tts_blast`/`tts_ivr` treatments
            (one render per distinct text). Capped at 2000 characters —
            several minutes of speech — so the renderer is never handed an
            unbounded document. `""` clears it.
        ivr_menu:
          type: object
          description: >-
            Digit → action map dispatched on a keypress, flat
            (`{"1": "transfer_agent", "9": "optout"}`) or nested
            (`{"prompt": "...", "entries": {"1": {"action": "hangup"}, "2":
            {"submenu": {...}}}}`). Keys are single DTMF keys; actions are
            `transfer_agent`, `optout`, `hangup`; depth ≤ 5, ≤ 64 nodes. A
            menu that does not validate is `400 invalid` with the engine's
            reason (`Dialer.IVR.Menu.validate/1`) — it is never stored.
            `{}` = no menu.
          additionalProperties: true
        deferred_bridge:
          type: boolean
          description: >-
            Agent campaigns: park the consumer on answer and bridge to the
            agent only after a HUMAN AMD verdict. Effective only while AMD is
            on for the campaign.
        consult_config:
          type: object
          description: >-
            Consult-line IVR config, CLOSED schema: `factor` (one of
            `account_last4`, `ssn_last4`, `dob`, `pin`, `zip`), optional
            `max_attempts` (1..10), optional `expose` (subset of `balance`,
            `account_status`, `minimum_payment`, `due_date`, `last4_account`),
            optional `factor_opts` (`length` positive integer, `dob_format`
            `mmdd`|`mmddyyyy`). Any other key is `400 invalid` naming it. `{}`
            = disabled.
          additionalProperties: true
        carrier: { type: string }
        caller_ids:
          type: array
          items: { type: string }
        caller_id_pool:
          type: array
          items: { type: string, format: uuid }
    CampaignStartOpts:
      type: object
      description: |
        Optional runner opts, validated fail-closed (`400` on bad types).
        CLOSED: an unrecognized opt is `400 bad_request` naming it — a
        mistyped compliance opt must never start a campaign with the opt
        quietly unset.

        RETIRED — `ai_disclosure_configured` (MT-EP-03.11): the non-AI
        artificial-voice disclosure is no longer asserted at launch. Sending
        it is `400 bad_request` with a message pointing at
        `PUT /v1/artificial-voice-disclosure`, whose stored text is now the
        only source of that fact and is resolved per attempt.
      additionalProperties: false
      properties:
        tick_ms: { type: integer, exclusiveMinimum: 0 }
        batch_size: { type: integer, exclusiveMinimum: 0 }
        msgs_per_tick:
          type: integer
          exclusiveMinimum: 0
          description: SMS campaigns — default 1 (≈1 MPS, the long-code-safe 10DLC throughput).
        gateway:
          type: string
          pattern: "^[A-Za-z0-9._-]+$"
          description: Sofia profile identifier.
        agent_domain: { type: string }
        agent_sip_proxy:
          type: string
          description: "`\"\"` opts the campaign out of the deployment-wide proxy."
        call_ttl_ms: { type: integer, exclusiveMinimum: 0 }
        blocked_cooldown_ms: { type: integer, exclusiveMinimum: 0 }
    CampaignStatsResponse:
      type: object
      required: [campaign_id, state, running, stats]
      properties:
        campaign_id: { type: string, format: uuid }
        # `suspended_balance` (R5) is written by the PLATFORM, never by a client:
        # the ledger crossed the tenant's suspension floor and took the campaign
        # off the air. It returns to `running` on its own when the balance
        # recovers -- no call, no operator. See `Dialer.Billing.BalanceSignal`.
        state: { type: string, enum: [draft, running, paused, completed, archived, suspended_balance] }
        running: { type: boolean }
        stats:
          oneOf:
            - $ref: "#/components/schemas/VoiceRunnerStats"
            - $ref: "#/components/schemas/SmsRunnerStats"
            - type: "null"
    VoiceRunnerStats:
      type: object
      properties:
        dialed: { type: integer }
        blocked: { type: integer }
        finished: { type: integer }
        reaped: { type: integer }
        in_flight: { type: integer }
        # Ticks the pacing brake closed (`Pacing.acquire/1` refused the
        # originate). Cumulative for the runner's lifetime, like `dialed` and
        # `blocked`: a `running` campaign whose `dialed` stops moving while
        # `throttled` grows is braked by the abandonment controller (or an
        # empty token bucket), not starved of leads (integration-tests#239).
        throttled: { type: integer }
        pacing:
          type: object
          properties:
            ratio: { type: number }
            abandoned: { type: integer }
            connected: { type: integer }
            abandonment_rate: { type: number }
            tokens: { type: integer }
    SmsRunnerStats:
      type: object
      properties:
        sent: { type: integer }
        duplicate: { type: integer }
        blocked: { type: integer }
        failed: { type: integer }
        pending_cooldown: { type: integer }
    ManualDialResult:
      type: object
      required: [status]
      properties:
        status: { type: string, enum: [dialing, blocked] }
        call_id:
          type: string
          format: uuid
          description: Present when `status` is `dialing`.
        reason:
          $ref: "#/components/schemas/ComplianceGate"
          description: Present when `status` is `blocked` — a valid outcome the agent must see, not an error.
    PreviewLead:
      type: object
      properties:
        debt_id: { type: string, format: uuid }
        debt_ref: { type: string }
        account_number: { type: string }
        debt_type: { type: string, enum: [other, student_loan] }
        debt_state: { type: string, enum: [open, in_collection, promise_to_pay, paid, settled, disputed, closed] }
        contact_phone: { type: string, description: The agent needs the number to place the call. }
        line_type: { type: string, enum: [mobile, landline, voip, unknown] }
        consumer_state: { type: string }
        consumer_timezone: { type: string }
        last_attempt_at:
          oneOf: [{ type: string, format: date-time }, { type: "null" }]
    RewindFilters:
      type: object
      description: All filters optional; `amd_verdicts` is RESERVED (`422`, fail-closed, never ignored).
      properties:
        dispositions:
          type: array
          items: { type: string }
          description: Matched against each debt's LATEST disposition.
        max_attempts: { type: integer, exclusiveMinimum: 0, description: Count of original attempts. }
        last_attempt_older_than_days: { type: integer, minimum: 0 }
        keep_scheduled_callbacks:
          type: boolean
          default: true
          description: "`false` cancels the campaign's pending callbacks for the rewound debts (rewind only)."
        amd_verdicts:
          type: array
          items: { type: string }
          description: RESERVED — always answers `422 amd_filter_not_supported`.
      # Request-only schema (both rewind POSTs). `campaign_id` comes from the
      # path and is NOT accepted here: an unrecognized filter is a 400 that
      # names it, because a rewind that drops a narrowing filter re-queues MORE
      # leads than the operator asked for.
      additionalProperties: false
    RewindPreviewResult:
      type: object
      required: [matching, excluded_by_compliance, debt_ids]
      properties:
        matching: { type: integer }
        excluded_by_compliance:
          type: integer
          description: Matching debts the engine would block RIGHT NOW (informational).
        debt_ids:
          type: array
          items: { type: string, format: uuid }
    DebtImportItem:
      type: object
      required: [external_ref, consumer_ref]
      properties:
        external_ref: { type: string, description: THE Reg F counter key (upsert key). }
        consumer_ref: { type: string }
        debt_type: { type: string, enum: [other, student_loan] }
        account_number: { type: string }
        amount_cents: { type: integer }
        currency: { type: string }
        state: { type: string, enum: [open, in_collection, promise_to_pay, paid, settled, disputed, closed] }
        campaign_id: { type: string, format: uuid }
        prior_attempts_7d:
          type: integer
          minimum: 0
          description: >
            How many calls about THIS debt the client already placed from its
            previous system in the last 7 days. Without it the Reg F 7-in-7
            counter starts BLIND on a migrated portfolio — it only ever saw the
            attempts this platform placed — and a book of business moved
            mid-cycle can burn the legal cap in its first week.

            Materialized as attempt rows the gate already reads, so the gate
            itself is unchanged. It is a TOTAL for the window, not a delta:
            re-sending the same payload adds nothing, and re-sending a SMALLER
            number never retracts history (the count only goes up, like
            `last_conversation_at`). Values above 7 are stored as 7 — 7 already
            saturates every frequency cap the engine has, so the verdict is
            identical and the cap only bounds the write.

            A value that is not an integer >= 0 fails THAT item, like any other
            bad field on this route; omitting it changes nothing.
        contacts:
          type: array
          maxItems: 100
          items:
            type: object
            required: [phone_e164]
            properties:
              phone_e164: { type: string, description: E.164 (upsert key within the debt). }
              line_type: { type: string, enum: [mobile, landline, voip, unknown] }
              timezone: { type: string, description: IANA. }
              us_state: { type: string }
              city: { type: string }
              postal_code:
                type: string
                description: >
                  ZIP of the consumer. It is what resolves the NYC borough for the
                  per-debt cap: without it a NY contact cannot be ruled OUT of the
                  city and gets the strict cap. Refreshed on re-import only when the
                  payload carries it — omitting it, or sending it as `""`, never
                  clears a stored value, and sending it as `null` fails the item.
                  Same rule as `consumer_ref`, `timezone`, `us_state` and `city`:
                  the contract is one, even though this column is the only nullable
                  one of the six.
              is_primary:
                type: boolean
                description: >
                  Asserts THE primary contact of the debt. On import, `true` demotes
                  the previous primary (kept, never deleted) and promotes this one;
                  omitted or `false` leaves the existing primary untouched. More than
                  one per item fails the item.
              consumer_ref: { type: string }
    DebtSummary:
      type: object
      description: |
        One row of `GET /v1/debts`. NOT a `Debt` with fewer keys: the phone is
        masked and `account_number` is absent by construction (the read view does
        not select it).
      properties:
        id: { type: string, format: uuid }
        tenant_id: { type: string, format: uuid }
        campaign_id:
          oneOf: [{ type: string, format: uuid }, { type: "null" }]
        campaign_name:
          oneOf: [{ type: string }, { type: "null" }]
        external_ref: { type: string }
        consumer_ref: { type: string }
        debt_type: { type: string, enum: [other, student_loan] }
        amount_cents: { type: integer }
        currency: { type: string }
        state: { type: string, enum: [open, in_collection, promise_to_pay, paid, settled, disputed, closed] }
        last_conversation_at:
          oneOf: [{ type: string, format: date-time }, { type: "null" }]
        next_dialable_at:
          oneOf: [{ type: string, format: date-time }, { type: "null" }]
          description: Null means the debt is dialable now (not parked by the disposition engine).
        primary_phone_masked:
          oneOf: [{ type: string }, { type: "null" }]
          description: "`***` + last four of the primary contact; `null` when the debt has no contact."
          example: "***0100"
        primary_line_type:
          oneOf: [{ type: string, enum: [mobile, landline, voip, unknown] }, { type: "null" }]
        primary_timezone:
          oneOf: [{ type: string }, { type: "null" }]
        primary_us_state:
          oneOf: [{ type: string }, { type: "null" }]
        primary_city:
          oneOf: [{ type: string }, { type: "null" }]
        contact_count: { type: integer }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    DebtPage:
      type: object
      required: [debts, next_cursor]
      properties:
        debts:
          type: array
          items: { $ref: "#/components/schemas/DebtSummary" }
        next_cursor:
          oneOf: [{ type: string }, { type: "null" }]
          description: Opaque keyset cursor; `null` on the last page.
    Debt:
      type: object
      properties:
        id: { type: string, format: uuid }
        tenant_id: { type: string, format: uuid }
        campaign_id:
          oneOf: [{ type: string, format: uuid }, { type: "null" }]
        external_ref: { type: string }
        consumer_ref: { type: string }
        debt_type: { type: string, enum: [other, student_loan] }
        account_number: { type: string }
        amount_cents: { type: integer }
        currency: { type: string }
        state: { type: string, enum: [open, in_collection, promise_to_pay, paid, settled, disputed, closed] }
        last_conversation_at:
          oneOf: [{ type: string, format: date-time }, { type: "null" }]
        contacts:
          type: array
          items: { $ref: "#/components/schemas/Contact" }
          description: Present on `GET /v1/debts/{id}` only.
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    Contact:
      type: object
      properties:
        id: { type: string, format: uuid }
        debt_id: { type: string, format: uuid }
        consumer_ref: { type: string }
        phone_e164: { type: string }
        line_type: { type: string, enum: [mobile, landline, voip, unknown] }
        timezone:
          oneOf: [{ type: string }, { type: "null" }]
        us_state:
          oneOf: [{ type: string }, { type: "null" }]
        city:
          oneOf: [{ type: string }, { type: "null" }]
        postal_code:
          oneOf: [{ type: string }, { type: "null" }]
        is_primary: { type: boolean }
    CallAttempt:
      type: object
      description: |
        Append-only CDR row. Originals have `corrects_id: null`; corrections point
        at the original — the LATEST correction is the final disposition.
      properties:
        id: { type: string, format: uuid }
        tenant_id: { type: string, format: uuid }
        debt_id: { type: string, format: uuid }
        campaign_id:
          oneOf: [{ type: string, format: uuid }, { type: "null" }]
        agent_id:
          oneOf: [{ type: string, format: uuid }, { type: "null" }]
        consumer_ref: { type: string }
        debt_key: { type: string }
        call_uuid:
          oneOf: [{ type: string }, { type: "null" }]
        from_number:
          oneOf: [{ type: string }, { type: "null" }]
        to_number: { type: string }
        started_at:
          oneOf: [{ type: string, format: date-time }, { type: "null" }]
        answered_at:
          oneOf: [{ type: string, format: date-time }, { type: "null" }]
        ended_at:
          oneOf: [{ type: string, format: date-time }, { type: "null" }]
        disposition:
          oneOf:
            - type: string
            - type: "null"
          description: "Engine dispositions: `answered_human|abandoned|busy|no_answer|canceled|rejected|failed`; typed agent/AI outcomes are free-form (e.g. `promise_to_pay`)."
        hangup_cause:
          oneOf: [{ type: string }, { type: "null" }]
        sip_response_code:
          oneOf: [{ type: integer }, { type: "null" }]
        compliance_snapshot:
          oneOf: [{ type: object }, { type: "null" }]
          description: Frozen decision evidence at originate time.
        amd_verdict:
          oneOf: [{ type: string }, { type: "null" }]
        recording_uri:
          oneOf: [{ type: string }, { type: "null" }]
          description: Where the call recording landed (object path/URI); null when the call was not recorded.
        ai_used:
          type: boolean
          description: Whether the voice-AI bot ran on this call (the AI add-on meter key).
        note:
          oneOf: [{ type: string }, { type: "null" }]
          description: >-
            The agent's free-text note about the call, entered at disposition time on
            ANY disposition. It rides on the correction record that carries the
            disposition, so the ORIGINAL row is normally `null`. Encrypted at rest
            (per-tenant envelope) and never queried by content. `null` = no note, or a
            record written before the column existed.
        corrects_id:
          oneOf: [{ type: string, format: uuid }, { type: "null" }]
        inserted_at: { type: string, format: date-time }
    CallAttemptPage:
      type: object
      required: [call_attempts, next_cursor]
      properties:
        call_attempts:
          type: array
          items: { $ref: "#/components/schemas/CallAttempt" }
        next_cursor:
          oneOf: [{ type: string }, { type: "null" }]
          description: Opaque keyset cursor; `null` on the last page.
    CallPage:
      type: object
      required: [calls, next_cursor]
      properties:
        calls:
          type: array
          items: { $ref: "#/components/schemas/CallAttempt" }
        next_cursor:
          oneOf: [{ type: string }, { type: "null" }]
          description: Opaque keyset cursor; `null` on the last page.
    Promise:
      type: object
      properties:
        id: { type: string, format: uuid }
        tenant_id: { type: string, format: uuid }
        debt_id: { type: string, format: uuid }
        call_attempt_id:
          oneOf: [{ type: string, format: uuid }, { type: "null" }]
        agent_id:
          oneOf: [{ type: string, format: uuid }, { type: "null" }]
        amount_cents: { type: integer }
        currency: { type: string, enum: [MXN, USD] }
        promised_date: { type: string, format: date }
        status: { type: string, enum: [pending, kept, broken, cancelled] }
        note:
          oneOf: [{ type: string }, { type: "null" }]
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    Callback:
      type: object
      properties:
        id: { type: string, format: uuid }
        tenant_id: { type: string, format: uuid }
        debt_id: { type: string, format: uuid }
        contact_phone: { type: string }
        campaign_id:
          oneOf: [{ type: string, format: uuid }, { type: "null" }]
        agent_id:
          oneOf: [{ type: string, format: uuid }, { type: "null" }]
        scheduled_at: { type: string, format: date-time }
        note:
          oneOf: [{ type: string }, { type: "null" }]
        priority: { type: integer }
        status: { type: string, enum: [pending, done, cancelled, missed] }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    Agent:
      type: object
      properties:
        id: { type: string, format: uuid }
        tenant_id: { type: string, format: uuid }
        name: { type: string }
        email: { type: string }
        role: { type: string, enum: [agent, supervisor, admin] }
        status:
          type: string
          enum: [active, inactive]
          description: Roster flag — NOT presence.
        sip_extension:
          oneOf: [{ type: string }, { type: "null" }]
        device_mode: { type: string, enum: [browser, external] }
        login_enabled:
          type: boolean
          description: "Widget self-login state — true iff a password credential is set. The password hash itself is never exposed."
        presence:
          type: string
          enum: [offline, available, ringing, on_call, wrap_up, paused]
          description: |
            Live seat presence from the in-memory AgentFSM. `wrap_up` is after-call
            work (entered on hangup when the tenant's `agent_wrap_up_seconds` cap is
            above 0); `paused` is a seat taken out of rotation with a reason
            (ADR #101 D1, MT-ROAD-R11).
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    SipBootstrap:
      type: object
      description: |
        Softphone bootstrap issued at checkin when SBC provisioning is enabled.
        The ephemeral password lives ONLY in Kamailio's `agentcred` htable — never
        in Postgres, never logged. Absent entirely when provisioning is disabled.
      properties:
        extension: { type: string, description: Digest username (bare AOR). }
        password: { type: string, description: "Ephemeral, minted per checkin, NEVER persisted." }
        wss_url: { type: string, description: SOFTPHONE_WSS_URL. }
        domain: { type: string, description: SOFTPHONE_SIP_DOMAIN (From URI). }
        expires_at:
          type: string
          format: date-time
          description: >-
            When this credential stops working. The htable autoexpire horizon
            (24h) CAPPED by the agent's widget session, when one STILL governs
            it: that session's absolute cap is at most 12h and the server evicts
            the credential the moment it lapses, so the earlier of the two is the
            only horizon a client can plan a renewal against. Always in the
            future — a session whose cap has already lapsed reports the flat
            horizon, never a past instant for a credential just issued.
    SupervisionRequest:
      type: object
      required: [supervisor_ext]
      properties:
        supervisor_ext:
          type: string
          description: Must be an ACTIVE roster extension of THIS tenant with role `supervisor` or `admin` (ADR #116; `403 role_forbidden` otherwise); their checked-in device receives the supervision leg (answer within 20 s).
    SupervisionResult:
      type: object
      properties:
        action: { type: string, enum: [listen, whisper, barge, takeover] }
        call_id: { type: string }
        supervisor_ext: { type: string }
        session_id:
          oneOf: [{ type: string }, { type: "null" }]
          description: Supervisor leg uuid; `null` for takeover (no eavesdrop leg).
        audit_id: { type: string, format: uuid, description: The durable `supervision_actions` row. }
        to_number: { type: string, description: Masked last-4 (`***0184`) — the full E.164 never crosses this surface. }
    CallControlResult:
      type: object
      properties:
        verb: { type: string, enum: [hold, unhold, mute, unmute, hangup] }
        call_id: { type: string }
        audit_id: { type: string, format: uuid, description: The durable `call_control_actions` row. }
        to_number: { type: string, description: Masked last-4 (`***0184`) — the full E.164 never crosses this surface. }
    StatsSummary:
      type: object
      properties:
        date: { type: string, format: date }
        window: { type: string, const: utc_day }
        calls_today: { type: integer }
        connected_today: { type: integer }
        connect_rate: { type: number }
        agents_online: { type: integer }
        agents_available: { type: integer }
        campaigns_running: { type: integer }
    PromisesPipelineSummary:
      type: object
      properties:
        open:
          type: object
          properties:
            count: { type: integer }
            amount_cents:
              type: object
              description: Per-currency sums — currencies never sum together.
              additionalProperties: { type: integer }
        kept_rate_30d:
          oneOf: [{ type: number }, { type: "null" }]
          description: Over promises RESOLVED with `promised_date` in the last 30 days; `null` = none resolved.
        window_days: { type: integer }
    PenetrationRow:
      type: object
      properties:
        campaign_id:
          oneOf: [{ type: string, format: uuid }, { type: "null" }]
        unique_accounts: { type: integer }
        unique_consumers: { type: integer }
        attempted: { type: integer }
        reached: { type: integer }
        effective_contact: { type: integer }
        attempted_pct: { type: number }
        reached_pct: { type: number }
        effective_contact_pct: { type: number }
    PenetrationReport:
      type: object
      properties:
        totals: { $ref: "#/components/schemas/PenetrationRow" }
        by_campaign:
          type: array
          items: { $ref: "#/components/schemas/PenetrationRow" }
    BlockedStats:
      type: object
      properties:
        from: { type: string, format: date-time }
        to: { type: string, format: date-time }
        campaign_id:
          oneOf: [{ type: string, format: uuid }, { type: "null" }]
        total: { type: integer }
        by_gate:
          type: object
          description: Keys are the `ComplianceGate` vocabulary.
          additionalProperties: { type: integer }
        daily:
          type: array
          items:
            type: object
            properties:
              date: { type: string, format: date }
              total: { type: integer }
              by_gate:
                type: object
                additionalProperties: { type: integer }
    CapacityStats:
      type: object
      properties:
        limits:
          oneOf:
            - type: object
              properties:
                max_cps:
                  oneOf: [{ type: integer }, { type: "null" }]
                max_concurrent_channels:
                  oneOf: [{ type: integer }, { type: "null" }]
            - type: "null"
          description: >-
            Admin-set ceilings, or `null` when the gate fails closed (ceilings
            unreadable and never cached). Each ceiling is `null` when unset.
        channels_in_use: { type: integer }
        dials_last_second: { type: integer }
        supervision:
          type: object
          description: >-
            The SECOND ceiling (ADR #37): concurrent live-call supervision
            sessions, counted and bounded separately from the dialing lane so
            neither can starve the other. `limit` is the deploy-time
            `max_supervision_channels`; `in_use` counts (call, supervisor)
            sessions, so a listen → barge switch on one call counts once.
          properties:
            limit: { type: integer }
            in_use: { type: integer }
    SurveyResults:
      type: object
      required: [campaign_id, from, to, question_id, total, distribution]
      properties:
        campaign_id: { type: string, format: uuid }
        from: { type: string, format: date-time }
        to: { type: string, format: date-time }
        question_id:
          oneOf: [{ type: string }, { type: "null" }]
          description: The filter that was applied; `null` = every question.
        total: { type: integer, minimum: 0 }
        distribution:
          type: array
          description: One row per digit pressed at least once, sorted by digit.
          items:
            type: object
            required: [digit, count]
            properties:
              digit: { type: string, pattern: "^[0-9*#]$" }
              count: { type: integer, minimum: 1 }
    AiUsageStats:
      type: object
      description: |
        `billable_seconds` is the canonical unit; each usage row snapshots the rate
        it was metered under and is priced at ITS snapshot — rate changes never
        reprice history. `minute_rate_usd` (top level) is the rate currently in
        force. `daily` carries one entry per day AND rate.
      properties:
        from: { type: string, format: date-time }
        to: { type: string, format: date-time }
        campaign_id:
          oneOf: [{ type: string, format: uuid }, { type: "null" }]
        minute_rate_usd: { type: number }
        calls: { type: integer }
        billable_seconds: { type: integer }
        billable_minutes: { type: number }
        estimated_cost_usd: { type: number }
        daily:
          type: array
          items:
            type: object
            properties:
              date: { type: string, format: date }
              calls: { type: integer }
              billable_seconds: { type: integer }
              billable_minutes: { type: number }
              minute_rate_usd: { type: number }
              estimated_cost_usd: { type: number }
    SmsUsageStats:
      type: object
      description: |
        Off the unified ledger (`sms_segment` billable events, exactly-once at
        provider ACCEPT, each at its region-rate snapshot). Decimal amounts are
        rendered as JSON strings. `daily` carries one entry per day AND rate.
      properties:
        from: { type: string, format: date-time }
        to: { type: string, format: date-time }
        campaign_id:
          oneOf: [{ type: string, format: uuid }, { type: "null" }]
        segment_rates_usd:
          type: object
          description: Per-region rate currently in force (what new sends will snapshot).
          additionalProperties: { type: string }
        messages: { type: integer }
        segments: { type: string, description: Decimal as string. }
        amount_usd: { type: string, description: Decimal as string. }
        daily:
          type: array
          items:
            type: object
            properties:
              date: { type: string, format: date }
              messages: { type: integer }
              segments: { type: string }
              segment_rate_usd: { type: string }
              amount_usd: { type: string }
    FrozenInvoice:
      type: object
      description: |
        The ISSUED invoice document (MT-BIL-05): the frozen
        `invoices`/`invoice_lines` rows under their own column names, never
        a recompute. `period_from`/`period_to` are CLOSED dates (the last
        day the period includes) — unlike the read-model's half-open
        datetime window. `invoice_number` and `source: frozen_invoice` are
        how a client tells this shape from `BillingSummary` on the same
        route.
      properties:
        source: { type: string, const: frozen_invoice }
        tenant_id: { type: string, format: uuid }
        invoice_number: { type: integer, minimum: 1 }
        period_key: { type: string, pattern: '^\d{4}-\d{2}$' }
        period_from: { type: string, format: date }
        period_to: { type: string, format: date }
        currency: { type: string, const: USD }
        issued_at: { type: string, format: date-time }
        total: { type: string, description: Decimal as string. }
        line_items:
          type: array
          items: { $ref: "#/components/schemas/FrozenInvoiceLine" }
    FrozenInvoiceLine:
      type: object
      description: >
        One frozen line, in document order. `amount` is the only column
        with authority; `quantity`/`unit_price` are the meter metadata as
        frozen (`unit_price` is 0 where the line's rule is not
        quantity x unit_price). The `line_type` vocabulary is OPEN — new
        meters appear without a spec bump.
      properties:
        position: { type: integer, minimum: 1 }
        line_type: { type: string }
        description: { type: string }
        quantity: { type: string, description: Decimal as string. }
        unit_price: { type: string, description: Decimal as string. }
        amount: { type: string, description: Decimal as string. }
    BillingAccount:
      type: object
      description: |
        `balance` is null when the tenant has no wallet; `cap` is null whenever
        the alert lane is inert (see the operation description). The two are
        independent: a tenant can have money and no cap in force.
      required: [tenant_id, currency, billing_enabled, alert_thresholds, balance, cap]
      properties:
        tenant_id: { type: string, format: uuid }
        currency: { type: string, const: USD }
        billing_enabled:
          type: boolean
          description: False when the tenant has no plan row at all.
        scheme:
          type: [string, "null"]
          enum: [prepaid, postpaid, subscription, null]
        plan_status: { type: [string, "null"] }
        enforcement_mode:
          type: [string, "null"]
          enum: [graduated, hard_block, null]
          description: |
            `graduated` notifies the tenant on a threshold crossing; `hard_block`
            records the crossing silently. Neither decides WHETHER the block
            happens — that is unconditional (ADR #39). Read-only on this route.
        alert_thresholds:
          type: array
          description: Percentage points the tenant is warned at. Empty when billing is off.
          items: { type: integer }
        balance:
          type: [object, "null"]
          required: [cash_usd, grant_usd, deposit_usd, credit_limit_usd, available_usd]
          properties:
            cash_usd: { type: string, description: Decimal as string. }
            grant_usd: { type: string, description: Decimal as string. }
            deposit_usd: { type: string, description: Decimal as string. }
            credit_limit_usd: { type: string, description: Decimal as string. }
            available_usd:
              type: string
              description: |
                Decimal as string. `cash + grant + credit_limit − Σ(open holds)`:
                spendable right now.
          additionalProperties: false
        cap:
          type: [object, "null"]
          required: [scheme, period_key, limit_usd, spent_usd, used_pct]
          properties:
            scheme: { type: string, enum: [prepaid, postpaid, subscription] }
            period_key:
              type: string
              description: |
                The epoch the thresholds are armed against. It changes when the
                tenant is funded (prepaid/postpaid) or at a new subscription
                period, which is what re-arms the warnings with zero deletes.
            limit_usd: { type: string, description: Decimal as string. }
            spent_usd: { type: string, description: Decimal as string. }
            used_pct:
              type: [string, "null"]
              description: |
                Decimal as string. `spent / limit * 100`, floored to one
                decimal — never rounded up, so this number stays on the same
                side of every threshold as the alert emitter. Null when the
                limit is not positive — that is "there is no cap to be used",
                not "100% used". Served rather than left to the client because
                the alert threshold is itself a percentage, and two roundings
                of the same ratio are how a banner ends up claiming 80% next to
                a bar that has not reached it.
          additionalProperties: false
      additionalProperties: false
    BillingAlerts:
      type: object
      description: |
        Threshold crossings of the CURRENT billing epoch, newest first. Every
        field of every entry is the value recorded AT the crossing, not a
        recomputation against today's balance.
      required: [tenant_id, period_key, alerts]
      properties:
        tenant_id: { type: string, format: uuid }
        period_key:
          type: [string, "null"]
          description: |
            The epoch the crossings belong to, identical to
            `cap.period_key` of `GET /v1/billing/account`. Null when the lane
            has no epoch (no active plan, no wallet, no funding entry, or
            (subscription) no grant yet) — in which case `alerts` is empty.
        alerts:
          type: array
          items:
            type: object
            required:
              [threshold_pct, scheme, enforcement_mode, spent_usd, limit_usd, crossed_at]
            properties:
              threshold_pct:
                type: integer
                description: |
                  The percentage that fired, as configured in the plan's
                  `alert_thresholds` when the epoch was armed.
              scheme:
                type: string
                enum: [subscription, prepaid, postpaid]
              enforcement_mode:
                type: string
                enum: [graduated, hard_block]
                description: |
                  The mode in force AT the crossing. It can differ from the
                  tenant's mode today: that is the point of a snapshot.
              spent_usd:
                type: string
                description: Decimal as string. Spend AT the crossing, not today's.
              limit_usd:
                type: string
                description: Decimal as string. The cap AT the crossing, not today's.
              crossed_at:
                type: string
                format: date-time
                description: When the crossing was recorded (UTC, microseconds).
            additionalProperties: false
      additionalProperties: false
    BillingSummary:
      type: object
      description: >
        Each line-item kind carries its own extra fields (see the example);
        `kind` and `fiscal_bucket` are the two every line has. The enum below
        is the full set `Summary.compute/2` emits from the meters — it listed
        three of the seven until MT-D of macro-tasks#448 measured it.
        The four METERED lines (`house_minutes`, `inbound_minutes`, `sms`,
        `voice_ai`) additionally carry the tier's INCLUDED USAGE —
        `allowance_pool`, `included_allowance`, `consumed_quantity`,
        `overage_quantity` and `gross_amount_usd` — so the bill is auditable
        line by line: what the plan includes, what was measured, and what is
        left to charge (core#219, ADR #110).
        `voice_ai` carries, on top of those, the PUBLISHED add-on terms it is
        now charged under (core#220): `volume_threshold_minutes` /
        `base_minutes` / `base_amount_usd` / `volume_tier_minutes` /
        `volume_tier_rate_usd` / `volume_tier_amount_usd` for the volume tier
        ($0.20/min above 5,000 min in the period), `subtotal_usd` for the
        charge after it, and `discount_percent` / `discount_usd` /
        `discount_effective_from` / `discount_effective_to` for the tenant's
        design-partner discount (the three window fields are `null` when no
        grant is in force). The order the four rules apply in is
        allowance, then volume tier, then discount, then the $200 monthly
        minimum — which is why `subtotal_usd` is the number `discount_usd` is a
        percentage of, and why `amount_usd` can exceed it.

        `wav_surcharge` is +5 USD per billable seat and month while the
        tenant's recording format at the period boundary is `wav`; it carries
        `recording_format` (the codec that instant, `null` when the tenant had
        no recording policy yet) and the same `billable_seats` the `seats` line
        bills, seat floor included.


        DID overage is TWO lines since core#802 (ADR #108) and not one:
        `did_overage` covers the numbers DialerDigital provides (`telecom`)
        and `did_overage_customer` the tenant's own BYOC numbers (`saas`).
        The FRANCHISE is still one and the CHARGE is still the same total —
        the two `amount_usd` add up to what the single line billed, and each
        carries the `overage_dids` attributed to its own origin. A month
        closed before the split keeps its whole overage on `did_overage` and
        publishes `did_overage_customer` at zero (`source:
        closed_before_split`): frozen documents are never re-classified.
      properties:
        tenant_id: { type: string, format: uuid }
        tier: { type: string, enum: [starter, growth, scale, enterprise, dialer_core, compliance_pro, audit_shield] }
        tier_label: { type: string }
        voice_ai_enabled: { type: boolean }
        currency: { type: string, const: USD }
        period:
          type: object
          properties:
            from: { type: string, format: date-time }
            to: { type: string, format: date-time }
        line_items:
          type: array
          items:
            type: object
            required: [kind, cadence, amount_usd]
            properties:
              kind:
                type: string
                enum: [seats, voice_ai, house_minutes, inbound_minutes, did_rent, did_overage, did_overage_customer, sms, wav_surcharge]
              cadence: { type: string, enum: [monthly, metered] }
              amount_usd: { type: number }
              fiscal_bucket:
                type: string
                enum: [saas, telecom, pass_through, tax]
                description: >
                  How the line is classified fiscally (ADR #73): `saas` is
                  software revenue, `telecom` is telecommunications revenue,
                  `pass_through` is a regulatory surcharge the company remits
                  on its own obligation, and `tax` is money collected on behalf
                  of a jurisdiction. Published since MT-D of macro-tasks#448,
                  when the founder confirmed the framing; a document frozen
                  before that may carry no classification, and is not
                  re-classified.
              allowance_pool:
                type: string
                enum: [minutes, sms_segments, ai_minutes]
                description: >
                  Present on the four METERED lines only
                  (`house_minutes`, `inbound_minutes`, `sms`, `voice_ai`).
                  Names the `plan_tiers` catalog COLUMN this line's allowance
                  came out of, which is how a reader can tell that
                  `house_minutes` and `inbound_minutes` are SHARING one
                  `included_minutes` between them rather than carrying two
                  (core#219, ADR #110).
              included_allowance:
                type: number
                description: >
                  Units of this meter the tier already includes for the period,
                  as they apply to THIS line — for the two minute lines it is
                  the share of the pooled `included_minutes` proportional to
                  what each one consumed, and the two shares add up to the
                  column. **0 means the tier includes nothing on this axis**,
                  which is how a NULL catalog column reads — never unlimited —
                  and also what every non-`subscription` plan scheme resolves
                  to, since `prepaid` and `postpaid` never bought a tier
                  version. With 0 the line is charged exactly as it was before
                  this field existed.
              consumed_quantity:
                type: number
                description: >
                  What the meter measured over the period, before the
                  allowance. Same figure the line's own meter field carries
                  (`billable_minutes` / `segments`).
              overage_quantity:
                type: number
                description: >
                  The BILLABLE units: `max(consumed_quantity -
                  included_allowance, 0)`. Never negative — a meter cannot
                  produce a credit.
              gross_amount_usd:
                type: number
                description: >
                  The untouched LEDGER sum for this line, before the allowance
                  was applied. On the three ledger lines (`house_minutes`,
                  `inbound_minutes`, `sms`) `amount_usd` is the same proportion
                  of it that `overage_quantity` is of `consumed_quantity`, so
                  the two side by side are the audit of the deduction — and the
                  evidence that no `billable_events` row was re-rated. On
                  `voice_ai` that proportion is a FLOOR, not an equality: while
                  the add-on is enabled `amount_usd` is
                  `max(deducted, monthly_minimum_usd)` and can therefore exceed
                  `gross_amount_usd`.
            additionalProperties: true
        total_usd: { type: number }
    ViolationsPreventedReport:
      type: object
      properties:
        report: { type: string, const: violations_prevented }
        tenant_id: { type: string, format: uuid }
        generated_at: { type: string, format: date-time }
        period:
          type: object
          properties:
            from: { type: string, format: date-time }
            to: { type: string, format: date-time }
        totals:
          type: object
          properties:
            total: { type: integer }
            by_gate:
              type: object
              additionalProperties: { type: integer }
        by_campaign:
          type: array
          description: Sorted by blocked count (desc).
          items:
            type: object
            properties:
              campaign_id: { type: string, format: uuid }
              campaign_name: { type: string }
              total: { type: integer }
              by_gate:
                type: object
                additionalProperties: { type: integer }
        policy_versions:
          type: array
          items: { type: string }
        samples:
          type: array
          description: ≤3 most recent evidence rows PER gate, always masked (last-4 only).
          items:
            type: object
            properties:
              occurred_at: { type: string, format: date-time }
              gate: { $ref: "#/components/schemas/ComplianceGate" }
              rule_id: { type: string }
              campaign_id:
                oneOf: [{ type: string, format: uuid }, { type: "null" }]
              debt_id: { type: string, format: uuid }
              contact_phone_masked: { type: string }
              policy_version: { type: string }
    DefensePacket:
      type: object
      description: |
        The sealed evidence envelope. `integrity.digest` = SHA-256 over the
        canonical JSON WITHOUT the `integrity` key (keys sorted bytewise as UTF-8,
        no insignificant whitespace, scalars as standard JSON, timestamps RFC 3339
        UTC). To verify: decode, drop `integrity`, re-serialize canonically, hash,
        compare. `worm_attestation` stays `null` until WORM storage is real.
      properties:
        packet: { type: string, const: litigation_defense }
        packet_version: { type: integer }
        tenant_id: { type: string, format: uuid }
        packet_generated_at: { type: string, format: date-time }
        debt: { type: object }
        contacts:
          type: array
          items: { type: object }
        call_attempts:
          type: array
          description: FULL history, originals + corrections, each with the frozen compliance_snapshot.
          items: { type: object }
        conversations:
          type: array
          description: Reg F G5 contact anchors.
          items: { type: object }
        gate_blocks:
          type: array
          description: Every durable refusal (frozen reason_detail).
          items: { type: object }
        consents:
          type: array
          items: { type: object }
        dnc:
          type: object
          properties:
            tenant_listings:
              type: array
              items: { type: object }
            global_listings:
              type: array
              items: { type: object }
            reassigned_numbers:
              type: array
              items: { type: object }
        cease_and_desist:
          type: array
          description: Consumer-wide (`""` scope) + debt-scoped.
          items: { type: object }
        promises:
          type: array
          items: { type: object }
        callbacks:
          type: array
          items: { type: object }
        supervision_actions:
          type: array
          items: { type: object }
        policy_versions:
          type: array
          items: { type: string }
        integrity:
          type: object
          properties:
            algorithm: { type: string, const: sha256 }
            digest: { type: string, description: Hex sha256 over the canonical envelope without `integrity`. }
            canonicalization: { type: string }
            worm_attestation:
              oneOf: [{ type: object }, { type: "null" }]
    DID:
      type: object
      properties:
        id: { type: string, format: uuid }
        tenant_id: { type: string, format: uuid }
        e164: { type: string, description: Immutable after creation. }
        npa:
          oneOf: [{ type: string }, { type: "null" }]
        us_state:
          oneOf: [{ type: string }, { type: "null" }]
        attestation: { type: string, enum: [A, B, C, unknown], description: STIR/SHAKEN level the carrier signs. }
        status: { type: string, enum: [active, quarantine, retired] }
        origin:
          type: string
          enum: [house, customer]
          description: "`house` rows are admin-minted platform inventory; the tenant surface only creates `customer`."
        labels:
          type: array
          items: { type: string }
        notes:
          oneOf: [{ type: string }, { type: "null" }]
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    DidHealth:
      type: object
      description: |
        Computed over the compensating-record CDR: `dials` counts ORIGINAL rows with
        this `from_number`; `answered` counts originals whose hangup correction
        carries `answered_at`; `short_calls` are answered calls ended in under
        `short_call_seconds`. Rates are `null` when the denominator is zero.
      properties:
        did_id: { type: string, format: uuid }
        e164: { type: string }
        status: { type: string, enum: [active, quarantine, retired] }
        period:
          type: object
          properties:
            from: { type: string, format: date-time }
            to: { type: string, format: date-time }
        dials: { type: integer }
        answered: { type: integer }
        answer_rate:
          oneOf: [{ type: number }, { type: "null" }]
        short_calls: { type: integer }
        short_call_rate:
          oneOf: [{ type: number }, { type: "null" }]
        short_call_seconds: { type: integer }
        daily:
          type: array
          items:
            type: object
            properties:
              date: { type: string, format: date }
              dials: { type: integer }
              answered: { type: integer }
              short_calls: { type: integer }
        reputation:
          type: object
          description: Explicit nulls until an external feed is contracted — render as pending, not zero.
          properties:
            hiya:
              oneOf: [{ type: number }, { type: "null" }]
            tns:
              oneOf: [{ type: number }, { type: "null" }]
            note: { type: string }
    Carrier:
      type: object
      properties:
        id: { type: string, format: uuid }
        account_id: { type: string, format: uuid }
        tenant_id:
          oneOf: [{ type: string, format: uuid }, { type: "null" }]
          description: "`null` = empresa-wide (shared) trunk inherited by every sede."
        scope: { type: string, enum: [empresa, sede], description: Derived from `tenant_id`. }
        kind: { type: string, enum: [house, byoc] }
        name: { type: string }
        sip_proxy:
          oneOf: [{ type: string }, { type: "null" }]
        source_ips:
          type: array
          items: { type: string }
        status: { type: string, enum: [active, disabled, retired] }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    SbcStatus:
      type: object
      required: [status]
      properties:
        status:
          type: string
          enum: [provisioned, sbc_disabled]
          description: "`sbc_disabled` = no `KAMAILIO_RPC_URL` in this environment; the row's intent is recorded, not pushed."
    SmsProvider:
      type: object
      properties:
        id: { type: string, format: uuid }
        account_id: { type: string, format: uuid }
        tenant_id:
          oneOf: [{ type: string, format: uuid }, { type: "null" }]
          description: "`null` = empresa-wide (shared) provider inherited by every sede."
        scope: { type: string, enum: [empresa, sede], description: Derived from `tenant_id`. }
        adapter: { type: string, enum: [http, telnyx, mock] }
        region: { type: string, enum: [us, mx, eu] }
        name: { type: string }
        base_url:
          oneOf: [{ type: string }, { type: "null" }]
        has_secret:
          type: boolean
          description: The webhook secret NEVER rides the wire — this is the only signal a credential is stored.
        status: { type: string, enum: [active, disabled, retired] }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    WebhookEventName:
      type: string
      description: The closed v1 catalog of subscribable events (ADR #121).
      enum: [call.ended, call.answered, promise.recorded, sms.received, sms.optout, recording.available]
    WebhookEvent:
      type: object
      description: >-
        The v1 envelope of an outbound webhook (MT-CTI-04 phase 2, ADR #122) — the body
        the dispatcher POSTs to a subscribed endpoint (`crmEvent`). Serialized at the
        moment of the fact, in the same transaction, and never rebuilt.
      required: [event, event_id, occurred_at, tenant_id, data]
      properties:
        event: { $ref: "#/components/schemas/WebhookEventName" }
        event_id:
          type: string
          description: >-
            Idempotency key, unique per endpoint: dedupe on it. The id of the row the
            event announces (`call.ended`: the finalize correction; `promise.recorded`:
            the promise; `sms.*`: the ledger row; `recording.available`: the recording);
            `call.answered` has no row of its own and uses `<original attempt id>:answered`.
        occurred_at:
          type: string
          format: date-time
          description: When the fact happened (the call's `ended_at`/`answered_at`, the message's `occurred_at`, ...), not when it was sent.
        tenant_id: { type: string, format: uuid }
        data:
          description: The public projection of the resource — the same shape its `GET` answers.
          oneOf:
            - $ref: "#/components/schemas/CallAttempt"
            - $ref: "#/components/schemas/Promise"
            - $ref: "#/components/schemas/SmsMessage"
            - $ref: "#/components/schemas/Recording"
    WebhookEndpoint:
      type: object
      description: >-
        One outbound webhook endpoint. NEVER carries the signing secret: `secret_hint`
        is the last 4 characters of the CURRENT secret, enough to tell which one the
        CRM holds. `tenant_id` is implicit in the bearer.
      properties:
        id: { type: string, format: uuid }
        url: { type: string }
        events:
          type: array
          items: { $ref: "#/components/schemas/WebhookEventName" }
        enabled: { type: boolean }
        disabled_reason:
          oneOf: [{ type: string }, { type: "null" }]
          description: '`disabled_by_api` after a DELETE; `null` while enabled or after `PATCH {"enabled": false}` (that path records no reason).'
        secret_hint:
          type: string
          minLength: 4
          maxLength: 4
          description: Last 4 characters of the current signing secret.
        previous_secret_expires_at:
          oneOf: [{ type: string, format: date-time }, { type: "null" }]
          description: "End of the rotation window in which the previous secret still verifies; `null` = no previous secret verifies."
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    WebhookDeliveryStatus:
      type: string
      enum: [pending, retry, delivered, deadletter, endpoint_disabled]
      description: >-
        DERIVED, never stored: `pending` when the delivery has no attempt yet,
        otherwise the `outcome` of its LAST attempt.
    WebhookDelivery:
      type: object
      description: >-
        One domain event addressed to one endpoint, with the append-only
        attempts the dispatcher wrote for it. The event envelope is not
        projected here — `data` is the public `/v1` shape of the resource.
      properties:
        id: { type: string, format: uuid }
        endpoint_id: { type: string, format: uuid }
        event: { $ref: "#/components/schemas/WebhookEventName" }
        event_id:
          type: string
          description: Idempotency key the receiver dedupes on; unique per endpoint.
        occurred_at:
          type: string
          format: date-time
          description: When the FACT happened (not when it was sent).
        created_at:
          type: string
          format: date-time
          description: When the delivery was written, in the fact's own transaction.
        status: { $ref: "#/components/schemas/WebhookDeliveryStatus" }
        attempts:
          type: array
          description: Every attempt, oldest first. Empty while the delivery is `pending`.
          items: { $ref: "#/components/schemas/WebhookDeliveryAttempt" }
    WebhookDeliveryAttempt:
      type: object
      description: >-
        One attempt at sending a delivery. Append-only — never edited, never erased.
      properties:
        attempt: { type: integer, minimum: 1 }
        outcome:
          type: string
          enum: [retry, delivered, deadletter, endpoint_disabled]
        reason:
          type: string
          description: >-
            Closed vocabulary: `ok`, `egress_refused`, `endpoint_gone`,
            `endpoint_not_enabled`, `payload_too_large`, `unexpected_status`,
            `timeout`, `transport`, `protocol`, `body_too_large`, `exception`,
            and `manual_redelivery` — the only one no send produced: the
            operator asked for one via the redeliver endpoint.
        status_code:
          oneOf: [{ type: integer, minimum: 100, maximum: 599 }, { type: "null" }]
          description: The receiver's HTTP status; `null` when no response arrived.
        started_at: { type: string, format: date-time }
        finished_at: { type: string, format: date-time }
        next_attempt_at:
          oneOf: [{ type: string, format: date-time }, { type: "null" }]
          description: When the next attempt is due; non-null only on `retry`.
        response_excerpt:
          oneOf: [{ type: string }, { type: "null" }]
          description: First bytes of the response BODY (≤ 1 KiB). Never headers.
    SmsMessage:
      type: object
      description: One SMS ledger ROW (original or transition — `corrects_id` distinguishes). Body is a 40-char preview in lists.
      properties:
        id: { type: string, format: uuid }
        direction: { type: string, enum: [outbound, inbound] }
        status: { type: string, enum: [queued, sent, delivered, undelivered, failed, received] }
        from_e164:
          oneOf: [{ type: string }, { type: "null" }]
        to_e164: { type: string }
        body_preview:
          oneOf: [{ type: string }, { type: "null" }]
        segments:
          oneOf: [{ type: integer }, { type: "null" }]
        client_ref:
          oneOf: [{ type: string }, { type: "null" }]
        provider_id:
          oneOf: [{ type: string, format: uuid }, { type: "null" }]
        provider_message_id:
          oneOf: [{ type: string }, { type: "null" }]
        campaign_id:
          oneOf: [{ type: string, format: uuid }, { type: "null" }]
        debt_id:
          oneOf: [{ type: string, format: uuid }, { type: "null" }]
        occurred_at: { type: string, format: date-time }
        corrects_id:
          oneOf: [{ type: string, format: uuid }, { type: "null" }]
        recorded_at: { type: string, format: date-time }
    SmsMessageView:
      type: object
      description: The COLLAPSED single-message view — current status, per-status timestamps, frozen compliance snapshot, FULL body, ordered transitions.
      properties:
        id: { type: string, format: uuid }
        direction: { type: string, enum: [outbound, inbound] }
        status: { type: string, enum: [queued, sent, delivered, undelivered, failed, received] }
        from_e164:
          oneOf: [{ type: string }, { type: "null" }]
        to_e164: { type: string }
        body:
          oneOf: [{ type: string }, { type: "null" }]
        segments:
          oneOf: [{ type: integer }, { type: "null" }]
        client_ref:
          oneOf: [{ type: string }, { type: "null" }]
        provider_id:
          oneOf: [{ type: string, format: uuid }, { type: "null" }]
        provider_message_id:
          oneOf: [{ type: string }, { type: "null" }]
        campaign_id:
          oneOf: [{ type: string, format: uuid }, { type: "null" }]
        debt_id:
          oneOf: [{ type: string, format: uuid }, { type: "null" }]
        compliance_snapshot:
          oneOf: [{ type: object }, { type: "null" }]
        occurred_at: { type: string, format: date-time }
        timestamps:
          type: object
          description: Per-status timestamps (e.g. `queued`, `sent`, `delivered`).
          additionalProperties: { type: string, format: date-time }
        transitions:
          type: array
          items: { $ref: "#/components/schemas/SmsMessage" }
    InboundRoute:
      type: object
      properties:
        id: { type: string, format: uuid }
        tenant_id: { type: string, format: uuid }
        e164: { type: string, description: Immutable after creation. }
        dest_type:
          type: string
          enum: [agent, queue, ivr]
          description: "`agent` bridges straight to the registered agent; `ivr` runs the IVR engine on FreeSWITCH; `queue` anchors the leg on FreeSWITCH. Today the production FreeSWITCH image ships no queue extensions (ADR #104 D13: the park pen and `dd_queue_hold` are a pending `freeswitch` dependency, not yet delivered), so a queue leg is hung up by FreeSWITCH as `UNALLOCATED_NUMBER` regardless of the flag. Once they ship: with the tenant's `inbound_queue_stream_enabled` flag ON, `core` admits the call to the route's queue (`enqueued`), the extension answers with a generated tone, the owner offers it to the oldest Ready agent and bridges it (MT-CTI-17 FASE 3); flag OFF (default): the leg ends unanswered in the pen."
        dest_ref:
          oneOf: [{ type: string }, { type: "null" }]
        recording: { type: boolean }
        ai_enabled: { type: boolean }
        status: { type: string, enum: [active, disabled] }
        notes:
          oneOf: [{ type: string }, { type: "null" }]
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    WebhookAck:
      type: object
      required: [status]
      properties:
        status:
          type: string
          enum: [ok, duplicate, ignored]
          description: "`duplicate` = replay collapsed on the ledger's unique indexes; `ignored` = authenticated but unattributable (logged + counted, not retryable)."

    # ── voice-ai ────────────────────────────────────────────────────────────
    AiVoice:
      type: object
      required: [language, voice_id, pace]
      properties:
        language: { type: string, description: "BCP-47 (e.g. `en-US`)." }
        voice_id: { type: string, description: Provider voice identifier. }
        pace:
          type: number
          minimum: 0
          maximum: 1
          description: 0 = slow … 1 = fast (0.5 ≈ natural).

    AiDisclosure:
      type: object
      required: [text, mandated]
      properties:
        text: { type: string, description: "The opening artificial/prerecorded-voice disclosure (content-validated, EN/ES)." }
        mandated:
          type: boolean
          const: true
          description: Always true — the disclosure cannot be disabled.
        locked:
          type: boolean
          description: True once the agent has been `live`; `text` is then immutable.

    AiPolicy:
      type: object
      description: Negotiation guardrails the AI is bounded by.
      properties:
        max_settlement_pct:
          type: integer
          minimum: 0
          maximum: 100
          description: Max settlement as a percent of balance.
        min_payment_cents: { type: integer, minimum: 0 }
        max_plan_months: { type: integer, minimum: 1 }
        take_payments:
          type: string
          enum: [none, card_on_file]
        prohibited:
          type: array
          items: { type: string }
          description: Disallowed conduct/topics (free-form tags).

    AiHandoffRule:
      type: object
      required: [trigger, action, severity]
      properties:
        id:
          oneOf: [{ type: string, format: uuid }, { type: "null" }]
        trigger:
          type: string
          enum: [keywords, negative_sentiment, payment_over, human_requested]
        params:
          type: object
          description: "Trigger config, e.g. `{keywords:[...]}`, `{seconds:20}`, `{cents:50000}`."
          additionalProperties: true
        action:
          type: string
          enum: [transfer, offer_human, confirm_human]
        severity:
          type: string
          enum: [hard, soft]
        locked:
          type: boolean
          description: "`human_requested` is ALWAYS locked (never removable/editable)."

    AiAgent:
      type: object
      required: [id, name, status, policy_version]
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        status:
          type: string
          enum: [draft, live, paused, retired]
        voice: { $ref: "#/components/schemas/AiVoice" }
        disclosure: { $ref: "#/components/schemas/AiDisclosure" }
        policy: { $ref: "#/components/schemas/AiPolicy" }
        handoff_rules:
          type: array
          items: { $ref: "#/components/schemas/AiHandoffRule" }
        policy_version:
          type: integer
          minimum: 1
          description: Monotonic; increments on every versioned change. Transcripts cite it.
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    AiAgentWrite:
      type: object
      description: |
        Writable agent fields. On create, `name` + `disclosure.text` are required. On
        PATCH every field is optional (merge-update); each accepted versioned change
        bumps `policy_version`. CLOSED: an unrecognized key is `400 bad_request`
        naming it, never a silent drop.

        `policy_version` and `disclosure.locked` are both server-owned, but they are
        now refused DIFFERENTLY, and the difference is observable: `policy_version` is
        a TOP-LEVEL key, so sending it is `400 bad_request` naming it (MT-SEC-49);
        `disclosure.locked` is NESTED, and the strict scrub only inspects top-level
        keys, so it is still accepted and ignored.
      additionalProperties: false
      properties:
        name: { type: string }
        status: { type: string, enum: [draft, live, paused, retired] }
        voice: { $ref: "#/components/schemas/AiVoice" }
        disclosure:
          type: object
          properties:
            text: { type: string }
        policy: { $ref: "#/components/schemas/AiPolicy" }
        handoff_rules:
          type: array
          items: { $ref: "#/components/schemas/AiHandoffRule" }

    AiTestRequest:
      type: object
      required: [message]
      properties:
        session_id:
          type: string
          format: uuid
          description: Omit to start a new sandbox thread; echo to continue one.
        message: { type: string }

    AiTestReply:
      type: object
      required: [session_id, reply]
      properties:
        session_id: { type: string, format: uuid }
        reply: { type: string }

    AiConversationListItem:
      type: object
      required: [id, phase]
      properties:
        id: { type: string, format: uuid }
        call_id:
          oneOf: [{ type: string }, { type: "null" }]
          description: The call's FreeSWITCH uuid.
        ai_agent_id:
          oneOf: [{ type: string, format: uuid }, { type: "null" }]
        account_ref:
          oneOf: [{ type: string }, { type: "null" }]
        us_state:
          oneOf: [{ type: string }, { type: "null" }]
        phase:
          type: string
          enum: [disclosure, verification, negotiation, payment_setup, wrapup]
        duration_secs:
          oneOf: [{ type: integer }, { type: "null" }]
        sentiment:
          oneOf:
            - { type: string, enum: [calm, positive, frustrated, negative] }
            - { type: "null" }
        handoff_suggested: { type: boolean }
        started_at: { type: string, format: date-time }
        ended_at:
          oneOf: [{ type: string, format: date-time }, { type: "null" }]

    AiConversation:
      allOf:
        - $ref: "#/components/schemas/AiConversationListItem"
        - type: object
          properties:
            transcript:
              type: array
              items: { $ref: "#/components/schemas/AiConversationEvent" }
            outcome:
              oneOf: [{ $ref: "#/components/schemas/AiOutcome" }, { type: "null" }]

    AiConversationEvent:
      type: object
      description: One verbatim transcript utterance, stamped with the policy version in force.
      required: [role, text, at]
      properties:
        role:
          type: string
          enum: [ai, consumer, human, system]
        text: { type: string }
        at: { type: string, format: date-time }
        policy_version:
          oneOf: [{ type: integer }, { type: "null" }]
          description: The agent `policy_version` governing this utterance.

    AiOutcome:
      type: object
      description: |
        The conversation's single terminal result (settled at wrap-up). The canonical
        taxonomy is the exact set the runtime classifies and posts
        (arch/voiceai-runtime-interface.md). `ptp`/`payment_taken` carry an amount.
      required: [type]
      properties:
        type:
          type: string
          enum: [resolved, ptp, payment_taken, refused, dispute, callback, voicemail, handoff, failed]
        amount_cents:
          oneOf: [{ type: integer }, { type: "null" }]
          description: Present for `ptp`/`payment_taken`.
        detail:
          oneOf: [{ type: string }, { type: "null" }]

    AiConversationPage:
      type: object
      required: [ai_conversations, next_cursor]
      properties:
        ai_conversations:
          type: array
          items: { $ref: "#/components/schemas/AiConversationListItem" }
        next_cursor:
          oneOf: [{ type: string }, { type: "null" }]
          description: Opaque keyset cursor; `null` on the last page and for the `active=true` feed.

    AiPauseState:
      type: object
      required: [ai_dialing]
      properties:
        ai_dialing: { type: string, enum: [active, paused] }
        paused_at:
          oneOf: [{ type: string, format: date-time }, { type: "null" }]
        paused_by:
          oneOf: [{ type: string }, { type: "null" }]

    AiEligibilityItem:
      type: object
      required: [campaign_id, name, eligible]
      properties:
        campaign_id: { type: string, format: uuid }
        name: { type: string }
        eligible: { type: boolean }
        reason:
          oneOf: [{ type: string }, { type: "null" }]
          description: "Why it cannot originate (`ai_dialing_paused` / `disclosure_missing`); `null` when eligible."

    PlannedShift:
      type: object
      required:
        - id
        - agent_id
        - start_time
        - end_time
        - planned_state
      properties:
        id:
          type: string
          format: uuid
        agent_id:
          type: string
          format: uuid
        start_time:
          type: string
          format: date-time
        end_time:
          type: string
          format: date-time
        planned_state:
          type: string
          enum: [available, ringing, on_call, wrap_up, paused]

    AiMetricsToday:
      type: object
      description: Aggregate for the current UTC day.
      properties:
        date: { type: string, format: date }
        handled: { type: integer, description: AI-handled conversations today. }
        containment_rate:
          type: number
          description: Share resolved without human handoff (0..1).
        avg_handle_secs: { type: integer }
        handled_delta_pct:
          oneOf: [{ type: number }, { type: "null" }]
          description: "Percent change in `handled` vs. yesterday up to the SAME time-of-day; `null` when yesterday had none."
        handoffs:
          type: object
          properties:
            count: { type: integer }
            pct: { type: number }
        ai_minutes: { type: number, description: Billable AI minutes today (from the ai_usage meter). }
        billed_cents: { type: integer }
        ptp:
          type: object
          properties:
            count: { type: integer }
            amount_cents: { type: integer }
        payments:
          type: object
          properties:
            count: { type: integer }
            amount_cents: { type: integer }
        voicemail_drops:
          type: integer
          description: Conversations the runtime classified `voicemail` (its AMD verdict) today.
        compliance_violations: { type: integer }
