openapi: 3.1.0
info:
  title: Dialer Digital core API (v1)
  version: 0.151.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`.


    ## Rate limits (per API key, per person)


    Every `dd_` API key has its own request quota on `/v1`: two keys of the same

    tenant never share a budget. It is a sliding allowance (GCRA), per node,
    with

    a burst of one minute's worth. The default is generous and a per-tenant

    operator setting (`api_key_requests_per_minute`) adjusts it. The console

    (`ddu_`) and agent (`ddw_`) lanes are counted too, with the same algorithm
    and

    their own setting (`session_requests_per_minute`): the bucket belongs to the

    person (the agent, else the user), not to the session, so logging in again
    does

    not buy a fresh budget, and another person of the same tenant is unaffected.

    Every response to a counted request carries `X-RateLimit-Limit`,

    `X-RateLimit-Remaining` and `X-RateLimit-Reset`; over the quota the answer
    is

    `429 rate_limited` with `Retry-After` and the request is not processed.

    Exempt: `POST /v1/calls/{call_id}/hangup`, webhooks and health routes. A

    request refused by the quota is not counted again, so waiting `Retry-After`

    seconds is always enough.


    ## 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 rate_limited` — the per-API-key request quota (below), or one of the
      fixed-window request brakes (login, second factor, widget tokens, call
      control, dispositions, invitations). These carry `Retry-After`: whole seconds until
      a retry can succeed. The quota also carries `X-RateLimit-*`.
    - `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. Neither carries `Retry-After`: the engine does not know
      when capacity returns, and a made-up number is worse than none.
    - `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
  license:
    name: Proprietary — Dialer Digital
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: skip-trace
    description: "Skip tracing (F0): a measured vendor lookup behind a per-tenant
      monthly cap with a hard cut. No invoice line yet."
  - 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: sso
    description: "SAML 2.0 single sign-on per tenant: the tenant's identity-provider
      configuration (loaded by an `account_admin`, audited append-only) and the
      service-provider metadata its IdP admin needs. SP-initiated only; a person
      must already exist in the tenant (no just-in-time provisioning)."
  - name: support-access
    description: "Platform-support access (ADR 225): the customer's consent (a grant
      with a reason and a fixed expiry, one live per tenant, revoked at will)
      and the append-only trail of it. `account_admin` only, except the banner."
  - 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: click-to-call
    description: "Click-to-call for the customer's website (ADR 220): the console
      mints/revokes a PUBLISHABLE `ddc_` site key, and the visitor's browser
      uses it to read the form's config and to leave a call request. Gated by
      `click_to_call_enabled`."
  - name: campaigns
    description: Campaign CRUD, lifecycle, manual dial, rewind.
  - name: debts
    description: Bulk lead import, debt detail, per-debt CDR, defense packet.
  - name: consumers
    description: "Debtor card by `consumer_ref`: debts, DNC / cease-and-desist, Reg
      F counter and next permitted window, derived from the append-only
      sources."
  - name: debt-lists
    description: "Reusable lists of debts, associated with campaigns (ADR #162)."
  - name: import-mappings
    description: CSV import templates (column -> field mapping and parse options),
      versioned append-only per tenant.
  - name: qm
    description: "Quality-management scoring forms (ADR #217), versioned append-only
      per tenant. Writes need the `qm_enabled` flag."
  - name: insights
    description: "Anomaly evidence of the insights evaluator (core#1983): contact,
      abandon, cost, gate-block and media-timeout series, each anomaly with its
      series and threshold. Always on; `insights_anomaly_k` only tunes the
      sensitivity."
  - name: product-analytics
    description: "Product analytics and NPS inside the console, our own and with no
      third party (macro-tasks#876): usage events as keys of a closed catalog,
      and the NPS question for account administrators. Always on; it never
      carries a consumer's data."
  - name: disclosure-scripts
    description: "Collection-notice script library (ADR #219): immutable versions
      per tenant, approved by a sealed event."
  - name: segment-filters
    description: "Saved segmentation filters (the criteria of `GET /v1/debts` with a
      name), versioned append-only per tenant. Behind the
      `debt_segmentation_enabled` flag (ADR #221)."
  - name: call-attempts
    description: Append-only CDR query + typed dispositions.
  - name: callbacks
    description: Consumer-agreed redial appointments.
  - name: disposition-rules
    description: "The tenant's retry policy by call outcome (R10): reschedule or
      suppress a debt after a given disposition. It can only postpone or close a
      debt; every dial still runs the compliance gates at originate time."
  - name: dispositions
    description: "The `dialer.v1.Disposition` contract set in REST spelling: the
      lowercase value, its enum name and number."
  - name: promises
    description: Promise-to-pay pipeline.
  - name: agents
    description: Seat roster, presence, softphone checkin/checkout.
  - name: wfm
    description: "Workforce management: the per-interval coverage of a day (forecast
      from the CDR, agents required by Erlang C against the tenant's service
      target, agents planned in R11 shifts). A derived read; nothing is
      persisted."
  - name: academy
    description: In-app Academy training records — the auditable constancia (who
      completed what and when), append-only and per agent.
  - 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: transfer-destinations
    description: The tenant's registry of the external numbers a live call may be
      transferred to.
  - name: rewind-segments
    description: Named sets of rewind filters a supervisor saves per tenant and
      re-uses (the filters, never a list of accounts).
  - 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) and the
      audit-chain citation: resolving a `decision_id` into its sealed link plus
      the integrity verdict over it."
  - name: dids
    description: DID registry, CDR-computed health, launch checklist and carrier tickets.
  - name: support
    description: Support tickets with a number and a first-response target by plan level.
  - 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: messaging
    description: 10DLC brand and campaign registration through the SMS provider, and
      the numbers of an approved campaign. Provider fees land on the balance at
      cost.
  - name: email
    description: Debtor email addresses with provenance, the suppression ledger
      (read) and the email ledger (read).
  - name: email-providers
    description: Per-tenant/region email provider registry (SMTP / log).
  - name: email-templates
    description: Per-tenant e-mail templates (static subject + body with typed
      variables), append-only versions, account_admin approval and retirement.
  - name: sms-templates
    description: Per-tenant SMS templates with typed variables, approval and the
      "templates only" setting.
  - 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: inbound-ivr-menus
    description: "The tenant's inbound IVR menus: published versions, append-only
      history, rendered to audio when published. An inbound route with
      `dest_type: ivr` runs one of them."
  - name: inbound-queues
    description: "The tenant's inbound queues: created with a name, an optional
      overflow to another of its queues, retired when nothing uses them. A route
      with `dest_type: queue` names one in `queue_id`; an inbound IVR menu names
      one as `queue:<id>`."
  - name: recordings
    description: "Call-recording evidence: retention-locked metadata, signed
      playback, legal hold, bulk export, per-tenant policy."
  - 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:
  /v1/me:
    get:
      operationId: getMe
      tags:
        - identity
      summary: Authenticated tenant, user and derived SEAT
      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.

        Also answers "which seat am I?" (`seat` + `seat_source`, core#1059,
        ADR #137). A client MUST NOT let the operator PICK a seat: since
        core#1034 (ADR #131) `POST /v1/agents/checkin` DERIVES the seat from
        the bearer and answers `403 seat_mismatch` to any `sip_extension` in
        the body that does not match it. The seat here is derived by the same
        single derivation point the check-in and `GET /v1/me/scorecard`
        (ADR #132) use, so the two answers cannot disagree.

        `seat_source` is ALWAYS present — a client never has to interpret a
        missing field:

          * `token` — `seat` is the caller's own agent row, in the same
            projection `GET /v1/agents/{id}` returns.
          * `no_identity` — a `dd_` machine key: no person behind it, so there
            is no "own" seat. `seat` is `null`; the body is NOT consulted.
          * `seat_not_linked` — a `ddu_` person with no active seat (e.g. a
            supervisor with no dialing position). `seat` is `null`.
          * `seat_gone` — the bearer points at a deleted agent row. Reachable
            only by the `ddw_` lane, which this route denies before the handler
            (not on the widget allowlist); listed for completeness.

        All four are **200**. A person without a seat is a legitimate state and
        this route doubles as the SPA's bearer validator, so answering `403`
        there would break their login — that is the deliberate difference from
        `GET /v1/me/scorecard`, which IS `403` without a seat because there is
        no marker to show.

        A `dds_` support session (ADR #225) answers `seat: null`,
        `seat_source: no_identity` and a `support` block (`read_only` is always
        `true`, with the session's `expires_at` and the operator behind it) so a
        console can show that the session is read-only and when it ends.

        A `ddu_` login session answers a `session` block (core#2828) with the
        fixed `expires_at` of that session, so a console reloaded with only its
        cookie can still warn before the session ends.
      security:
        - tenantApiKey: []
        - userSessionToken: []
        - supportSessionToken: []
      responses:
        "200":
          description: The authenticated tenant, the user (ddu_) and the derived seat.
          headers:
            X-Dialer-Support-Session:
              description: Present, `read-only`, on every response to a `dds_` support
                session.
              schema:
                type: string
                enum:
                  - read-only
          content:
            application/json:
              schema:
                type: object
                required:
                  - tenant
                  - seat
                  - seat_source
                  - capabilities
                  - screen_monitor
                properties:
                  tenant:
                    $ref: "#/components/schemas/Tenant"
                  user:
                    $ref: "#/components/schemas/User"
                  support:
                    type: object
                    additionalProperties: false
                    required:
                      - read_only
                      - session_id
                      - expires_at
                      - operator
                    description: "Only for a `dds_` support session (ADR #225): the session is
                      read-only and ends at `expires_at`."
                    properties:
                      read_only:
                        type: boolean
                        enum:
                          - true
                      session_id:
                        type: string
                        format: uuid
                      expires_at:
                        type: string
                        format: date-time
                      operator:
                        type: string
                        description: The platform operator who opened it, `operator:<id> (<name>)`.
                  session:
                    type: object
                    additionalProperties: false
                    required:
                      - expires_at
                    description: 'Only for a `ddu_` login session (core#2828): when it ends.
                      `expires_at` is the FIXED absolute expiry set at login (it
                      never moves, no renewal extends it) and is the same
                      instant the login, MFA and SSO answers give as
                      `session.expires_at`. It lets a console that reloaded with
                      only its `HttpOnly` cookie know the end of the session.
                      Absent for a `dd_` machine key and for a `dds_` support
                      session (the latter has `support.expires_at`); a client
                      MUST read a missing key as "unknown", never as "does not
                      expire".'
                    properties:
                      expires_at:
                        type: string
                        format: date-time
                  seat:
                    oneOf:
                      - $ref: "#/components/schemas/Agent"
                      - type: "null"
                    description: The caller's own seat, or `null` when `seat_source` is not `token`.
                  seat_source:
                    type: string
                    enum:
                      - token
                      - no_identity
                      - seat_not_linked
                      - seat_gone
                    description: How the seat was resolved (or why there is none). Always present.
                  capabilities:
                    type: object
                    additionalProperties: false
                    required:
                      - conference
                    description: Per-tenant UI capabilities (core#1806), a closed list. A capability
                      that cannot be resolved reads `false`.
                    properties:
                      conference:
                        type: boolean
                        description: "`true` when the internal conference routes (`POST
                          /v1/calls/{call_id}/conference/add|remove`) are
                          enabled for this tenant; `false` means they answer
                          404."
                      recording_pause:
                        type: boolean
                        description: "`true` when the tenant's `recording_pause_enabled` flag is on
                          (core#2748): the call-recording pause and resume
                          routes may act; `false` means they answer 404. Absent
                          from a core that predates it: read a missing key as
                          \"not published yet\", never as `false`."
                      email_channel:
                        type: boolean
                        description: "`true` when the tenant's `email_channel_enabled` flag is on
                          (core#1925): the email sender and the `email_blast`
                          runner may act. `false` (the default) means the
                          channel is off: it writes no ledger row and calls no
                          provider. It is NOT \"ready to send\": an active
                          provider, the budget, the address provenance and every
                          gate still apply, and core answers their typed errors.
                          It is absent from a core that predates it, so a client
                          MUST read a missing key as \"not published yet\" and
                          never as `false`."
                      click_to_call:
                        type: boolean
                        description: "`true` when the public click-to-call lane is ON for this tenant
                          (core#2705): the cell ceiling is open AND the tenant's
                          `click_to_call_enabled` override is `true`. `false`
                          (the default) means the public routes answer 404 and
                          write nothing, so a snippet published now would do
                          nothing. Absent from a core that predates it: read a
                          missing key as \"not published yet\", never as
                          `false`."
                      widget_auth:
                        type: boolean
                        description: "`true` when the `ddw_` widget agent lane is ON for this tenant
                          (core#2705): the cell ceiling is open AND the tenant's
                          `widget_auth_enabled` override is `true`. `false` (the
                          default) means widget agent logins and requests are
                          refused. Absent from a core that predates it: read a
                          missing key as \"not published yet\", never as
                          `false`."
                      debt_segmentation:
                        type: boolean
                        description: "`true` when the tenant's `debt_segmentation_enabled` flag resolves
                          on (core#2941), cell ceiling included when the key has
                          one. `false` means the surface it governs is off for
                          this tenant. Absent from a core that predates it: read
                          a missing key as \"not published yet\", never as
                          `false`."
                      qm:
                        type: boolean
                        description: "`true` when the tenant's `qm_enabled` flag resolves on
                          (core#2941), cell ceiling included when the key has
                          one. `false` means the surface it governs is off for
                          this tenant. Absent from a core that predates it: read
                          a missing key as \"not published yet\", never as
                          `false`."
                      report_builder:
                        type: boolean
                        description: "`true` when the tenant's `report_builder_enabled` flag resolves on
                          (core#2941), cell ceiling included when the key has
                          one. `false` means the surface it governs is off for
                          this tenant. Absent from a core that predates it: read
                          a missing key as \"not published yet\", never as
                          `false`."
                      screen_monitor:
                        type: boolean
                        description: "`true` when the tenant's `screen_monitor_enabled` flag resolves on
                          (core#2941), cell ceiling included when the key has
                          one. `false` means the surface it governs is off for
                          this tenant. Absent from a core that predates it: read
                          a missing key as \"not published yet\", never as
                          `false`."
                      billing_cost:
                        type: boolean
                        description: "`true` when the tenant's `billing_cost_enabled` flag resolves on
                          (core#2941), cell ceiling included when the key has
                          one. `false` means the surface it governs is off for
                          this tenant. Absent from a core that predates it: read
                          a missing key as \"not published yet\", never as
                          `false`."
                      billing_ledger:
                        type: boolean
                        description: "`true` when the tenant's `billing_ledger_enabled` flag resolves on
                          (core#2941), cell ceiling included when the key has
                          one. `false` means the surface it governs is off for
                          this tenant. Absent from a core that predates it: read
                          a missing key as \"not published yet\", never as
                          `false`."
                      inbound_queue_stream:
                        type: boolean
                        description: "`true` when the tenant's `inbound_queue_stream_enabled` flag
                          resolves on (core#2941), cell ceiling included when
                          the key has one. `false` means the surface it governs
                          is off for this tenant. Absent from a core that
                          predates it: read a missing key as \"not published
                          yet\", never as `false`."
                      inbound_ai:
                        type: boolean
                        description: "`true` when the tenant's `inbound_ai_enabled` flag resolves on
                          (core#2941), cell ceiling included when the key has
                          one. `false` means the surface it governs is off for
                          this tenant. Absent from a core that predates it: read
                          a missing key as \"not published yet\", never as
                          `false`."
                      sms_auto_reply:
                        type: boolean
                        description: "`true` when the tenant's `sms_auto_reply_enabled` flag resolves on
                          (core#2941), cell ceiling included when the key has
                          one. `false` means the surface it governs is off for
                          this tenant. Absent from a core that predates it: read
                          a missing key as \"not published yet\", never as
                          `false`."
                      agent_session_journal:
                        type: boolean
                        description: "`true` when the tenant's `agent_session_journal_enabled` flag
                          resolves on (core#2941), cell ceiling included when
                          the key has one. `false` means the surface it governs
                          is off for this tenant. Absent from a core that
                          predates it: read a missing key as \"not published
                          yet\", never as `false`."
                      live_floor_seat_lane:
                        type: boolean
                        description: "`true` when the tenant's `live_floor_seat_lane` flag resolves on
                          (core#2941), cell ceiling included when the key has
                          one. `false` means the surface it governs is off for
                          this tenant. Absent from a core that predates it: read
                          a missing key as \"not published yet\", never as
                          `false`."
                      conference_external:
                        type: boolean
                        description: "`true` when the tenant's `conference_external_enabled` flag
                          resolves on (core#2941), cell ceiling included when
                          the key has one. `false` means the surface it governs
                          is off for this tenant. Absent from a core that
                          predates it: read a missing key as \"not published
                          yet\", never as `false`."
                      support_access:
                        type: boolean
                        description: "`true` when the tenant's `support_access_enabled` flag resolves on
                          (core#2941), cell ceiling included when the key has
                          one. `false` means the surface it governs is off for
                          this tenant. Absent from a core that predates it: read
                          a missing key as \"not published yet\", never as
                          `false`."
                      inbound_callback_offer:
                        type: boolean
                        description: "`true` when the tenant's `inbound_callback_offer_enabled` flag
                          resolves on (core#2941), cell ceiling included when
                          the key has one. `false` means the surface it governs
                          is off for this tenant. Absent from a core that
                          predates it: read a missing key as \"not published
                          yet\", never as `false`."
                      inbound_queue_skill_routing:
                        type: boolean
                        description: "`true` when the tenant's `inbound_queue_skill_routing_enabled`
                          flag resolves on (core#2941), cell ceiling included
                          when the key has one. `false` means the surface it
                          governs is off for this tenant. Absent from a core
                          that predates it: read a missing key as \"not
                          published yet\", never as `false`."
                      ai_take_payments:
                        type: boolean
                        description: "`true` when the tenant's `ai_take_payments_enabled` flag resolves
                          on (core#2941), cell ceiling included when the key has
                          one. `false` means the surface it governs is off for
                          this tenant. Absent from a core that predates it: read
                          a missing key as \"not published yet\", never as
                          `false`."
                      privacy_subject_export:
                        type: boolean
                        description: "`true` when the tenant's `privacy_subject_export_enabled` flag
                          resolves on (core#2941), cell ceiling included when
                          the key has one. `false` means the surface it governs
                          is off for this tenant. Absent from a core that
                          predates it: read a missing key as \"not published
                          yet\", never as `false`."
                      privacy_erasure:
                        type: boolean
                        description: "`true` when the tenant's `privacy_erasure_enabled` flag resolves
                          on (core#2941), cell ceiling included when the key has
                          one. `false` means the surface it governs is off for
                          this tenant. Absent from a core that predates it: read
                          a missing key as \"not published yet\", never as
                          `false`."
                  screen_monitor:
                    $ref: "#/components/schemas/ScreenMonitorStatus"
              example:
                tenant:
                  id: 0d4f4f9e-1f2a-4b53-9d3c-8a5e2f7b1c10
                  name: Acme Collections
                  status: active
                  synthetic: false
                  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
                seat: null
                seat_source: no_identity
                capabilities:
                  conference: false
                  recording_pause: false
                  email_channel: false
                  click_to_call: false
                  widget_auth: false
                  debt_segmentation: false
                  qm: false
                  report_builder: true
                  screen_monitor: false
                  billing_cost: false
                  billing_ledger: false
                  inbound_queue_stream: false
                  inbound_ai: false
                  sms_auto_reply: false
                  agent_session_journal: false
                  live_floor_seat_lane: false
                  conference_external: false
                  support_access: false
                  inbound_callback_offer: false
                  inbound_queue_skill_routing: false
                  ai_take_payments: false
                  privacy_subject_export: false
                  privacy_erasure: false
                screen_monitor:
                  enabled: false
                  notice_ack_required: false
                  notice_version: null
                  scopes:
                    live_monitoring:
                      ack_current: false
                      acked_at: null
                    screen_recording:
                      ack_current: false
                      acked_at: null
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/me/scorecard:
    get:
      operationId: getMeScorecard
      tags:
        - identity
      summary: The caller's OWN shift scorecard (seat derived from the token)
      description: |
        The collector's personal marker: their calls in the window and the
        promises they took, for the seat the BEARER identifies. No parameter
        may name an agent — `agent_id` is refused by name with `400`, never
        ignored, so a client can never believe it read a colleague's numbers.

        Who gets a seat: a `ddu_` dashboard login, through the
        `users_agents.user_id` link (`403 seat_not_linked` when the person has
        no active seat). A `dd_` machine key has no person behind it and is
        `403`. A `ddw_` widget token is `403` before the handler — this route
        is not on the widget allowlist.

        Window: half-open `[from, to)`. Both are optional and `to` defaults to
        now. `from` defaults to the seat's ACTIVE check-in
        (`window.source: checkin`); when the seat has no active check-in —
        the normal state right after checkout, since `checked_in_at` is
        presence, not history — it defaults to `now - 12h`
        (`window.source: fallback_12h`). Twelve hours cover a whole collection
        shift without dragging in yesterday's. A client MUST surface `source`:
        a figure from an estimated window is shown as such, never as "the
        shift". `calls` counts ATTEMPTS, not CDR rows
        (the original plus its corrections are one call), and carries no ratios
        or minutes — derived occupancy is `GET /v1/agents/occupancy`, and two
        computations of the same thing is exactly what is avoided here.

        Deliberately absent: the promise `note` (free text, encrypted at rest
        under GLBA), phone numbers, and commission.
      security:
        - userSessionToken: []
      parameters:
        - name: from
          in: query
          required: false
          schema:
            type: string
            format: date-time
          description: |
            Inclusive start (RFC 3339). Defaults to the seat's active
            `checked_in_at`, or to `now - 12h` when there is none — see
            `window.source`.
        - name: to
          in: query
          required: false
          schema:
            type: string
            format: date-time
          description: |
            Exclusive end (RFC 3339); must be after `from`. Defaults to now.
      responses:
        "200":
          description: The caller's own scorecard for the resolved window.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MeScorecard"
        "400":
          description: |
            Malformed window, or an `agent_id` parameter — refused by name
            because this scorecard is always the caller's own seat.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: bad_request
                  message: "agent_id not accepted here: this scorecard is always the caller's own
                    seat, derived from the token — use /v1/agents/occupancy or
                    /v1/calls for another seat"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
  /v1/me/evaluations:
    get:
      operationId: listMeEvaluations
      tags:
        - qm
      summary: The caller's OWN QM evaluations, newest first
      description: >
        The evaluations of the seat the BEARER identifies, corrections included,
        capped

        at 200. No parameter may name an agent: `agent_id`, `seat_id`, `user_id`
        and

        `sip_extension` are refused by name with `400`, never ignored, so a
        client can

        never believe it read a colleague's evaluations. The check comes AFTER
        the

        `403`, so a caller with no seat learns nothing about which parameters
        are read.


        Who gets a seat: a `ddu_` dashboard login linked to an active seat

        (`403 seat_not_linked` otherwise). A `dd_` machine key has no person
        behind it

        and is `403`. A `ddw_` widget token is `403` before the handler: this
        route is

        not on the widget allowlist.


        Each row carries the criteria answers, the score, the form version, the

        `comment` written for the agent, whether it is the `current` one, the
        state of

        the dispute (`dispute`, null when there is none) and `dispute_until`,
        the end of

        the `qm_dispute_window_days` window while the dispute can still be
        opened (null

        otherwise: replaced evaluation, dispute already open or resolved, fruit
        of a

        resolved dispute, window closed). The row does NOT say who evaluated,
        and it

        never carries the agent's own id. Reads work whether or not `qm_enabled`
        is on.
      security:
        - userSessionToken: []
      responses:
        "200":
          description: The caller's evaluations.
          content:
            application/json:
              schema:
                type: object
                required:
                  - evaluations
                properties:
                  evaluations:
                    type: array
                    items:
                      $ref: "#/components/schemas/QmAgentEvaluation"
        "400":
          description: |
            A parameter that names an agent (`agent_id`, `seat_id`, `user_id`,
            `sip_extension`), refused by name.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: bad_request
                  message: "agent_id not accepted here: these evaluations are always the caller's
                    own, derived from the token"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
  /v1/me/evaluations/{id}:
    get:
      operationId: getMeEvaluation
      tags:
        - qm
      summary: One of the caller's OWN QM evaluations, with its criteria and dispute
        thread
      description: >
        Same identity contract as `GET /v1/me/evaluations`: the seat comes from
        the

        token and the parameters that name an agent are `400`. Adds the
        `criteria` and

        `pass_threshold` of the form version the evaluation was scored against,
        and the

        `dispute_events` thread (kind, reason, note, time; no actor ids). An
        evaluation

        of another agent, of another tenant or that does not exist is the same
        `404`.
      security:
        - userSessionToken: []
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: The evaluation.
          content:
            application/json:
              schema:
                type: object
                required:
                  - evaluation
                properties:
                  evaluation:
                    $ref: "#/components/schemas/QmAgentEvaluationDetail"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
  /v1/me/evaluations/{id}/dispute:
    post:
      operationId: openMeEvaluationDispute
      tags:
        - qm
      summary: Open the dispute of one of the caller's OWN QM evaluations
      description: >
        The evaluated agent contests the evaluation: ONE dispute per evaluation,
        opened

        inside the `qm_dispute_window_days` window (1 to 90 days, 7 by default)
        counted

        from when the evaluation was written. Only the `ddu_` login of the agent
        the

        evaluation is about can open it, with an ACTIVE seat. A `dd_` key is a
        service,

        not a person: `403 role_forbidden`. A `ddw_` widget token is `403`
        before the

        handler: this route is not on the widget allowlist.


        The checks run in this order and the first one that fails writes
        nothing:

        tenant scope, `qm_enabled` (`403 qm_not_enabled`: reads continue with
        the flag

        off, writes do not), the person (`403 role_forbidden`), `404` if the id
        is not a

        UUID, `400` (the body: `reason` is one of `wrong_call`,
        `criterion_misjudged`,

        `other`, `note` is optional text, unknown fields are refused), `404`
        (the evaluation is not the

        caller's own, belongs to another tenant or does not exist: one answer
        for all

        three), then `409` when the evaluation was replaced by a newer one
        (dispute the

        current), already has its dispute or is itself the result of a resolved

        dispute (a resolved dispute is not reopened), or the window has closed.


        Answers with the evaluation as `GET /v1/me/evaluations/{id}` shows it,
        now with

        its `dispute` and the `opened` event in `dispute_events`. While the
        dispute is

        open the evaluation keeps counting: nothing hides or excludes it.
      security:
        - userSessionToken: []
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/QmDisputeOpen"
            example:
              reason: criterion_misjudged
              note: Dije el aviso de grabacion en el minuto 1:10 y el criterio lo marca como
                omitido.
      responses:
        "201":
          description: The evaluation with its dispute open.
          content:
            application/json:
              schema:
                type: object
                required:
                  - evaluation
                properties:
                  evaluation:
                    $ref: "#/components/schemas/QmAgentEvaluationDetail"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >
            `code` `qm_not_enabled`: the tenant's `qm_enabled` flag is off.
            `code`

            `role_forbidden`: the caller is a `dd_` key or a login with no
            active agent

            seat. `code` `forbidden`: the tenant is suspended or the token is a
            widget token.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: qm_not_enabled
                  message: QM is not enabled for this tenant
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
  /v1/screen-monitor/notice:
    get:
      operationId: getScreenMonitorNotice
      tags:
        - identity
      summary: The current screen-monitoring notice
      description: |
        The CURRENT version of the notice a seat must acknowledge before its
        screen may be observed (core#1989, FASE 0 of dashboard#696). The notice
        is a global catalogue (no tenant) and append-only: publishing a newer
        `version` invalidates, without touching them, every acknowledgement of
        the older ones.

        `404 not_found` when the tenant's founder-governed flag
        `screen_monitor_enabled` is off, and also when no version has been
        published yet. Reachable by a `dd_` key and by a `ddu_` login; a `ddw_`
        widget token is `403` before the handler.
      security:
        - tenantApiKey: []
        - userSessionToken: []
      responses:
        "200":
          description: The current notice.
          content:
            application/json:
              schema:
                type: object
                required:
                  - notice
                properties:
                  notice:
                    $ref: "#/components/schemas/ScreenMonitorNotice"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
  /v1/screen-monitor/acks:
    post:
      operationId: acknowledgeScreenMonitorNotice
      tags:
        - identity
      summary: Acknowledge the current screen-monitoring notice from one's OWN seat
      description: >
        Records that the caller's OWN seat accepts `notice_version`, which must

        be the CURRENT version, FOR ONE `scope`: `live_monitoring` or

        `screen_recording`. The scopes are acknowledged separately: accepting
        live

        monitoring does not authorize recording, and a new notice version

        invalidates both. Idempotent: a second acknowledgement of the same

        version and scope returns the first (`200`, no new row). The ledger is
        append-only

        (DELETE revoked and a trigger refuses UPDATE/DELETE).


        The seat is derived from the bearer, never from the body. Only a `ddu_`

        login with a linked seat may acknowledge: a `dd_` machine key has no

        person behind it (`403 forbidden`), a `ddu_` with no active seat is

        `403 seat_not_linked`, and an `agent_id` in the body that is not the

        caller's own seat is `403 forbidden` — a third party never acknowledges

        on someone else's behalf. A `ddw_` widget token is `403` before the

        handler.


        `404 not_found` while the tenant's flag `screen_monitor_enabled` is off.

        With the flag on, `409 no_notice_published` when no version exists and

        `409 stale_notice` when `notice_version` is not the current one.
      security:
        - userSessionToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - notice_version
                - scope
              additionalProperties: false
              properties:
                notice_version:
                  type: integer
                  minimum: 1
                  description: The version being accepted; it must be the current one.
                scope:
                  type: string
                  enum:
                    - live_monitoring
                    - screen_recording
                  description: What the seat accepts with this acknowledgement.
                agent_id:
                  type: string
                  format: uuid
                  description: Optional. If present it must be the caller's own seat; anything
                    else is `403`.
      responses:
        "200":
          description: The acknowledgement (the existing one when repeated).
          content:
            application/json:
              schema:
                type: object
                required:
                  - ack
                properties:
                  ack:
                    $ref: "#/components/schemas/ScreenMonitorAck"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: >
            `no_notice_published`: no notice version exists yet, so nobody can

            acknowledge. `stale_notice`: `notice_version` is not the current
            one.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: stale_notice
                  message: that notice version is not the current one
    get:
      operationId: listScreenMonitorAcks
      tags:
        - identity
      summary: Acknowledgements of the tenant (supervisor/admin seat)
      description: |
        The tenant's acknowledgements, newest first, paginated by cursor.
        Allowed only to a seat whose template role is `supervisor` or `admin`
        (`403 role_forbidden` otherwise; a `dd_` key and a `ddu_` with no seat
        are `403`). The role is evaluated always; it does not depend on
        `supervision_role_enforced`.

        It does NOT consult `screen_monitor_enabled`: it is the record of what
        was accepted, and turning the function off neither deletes nor hides it.
        Nothing crosses tenants.
      security:
        - userSessionToken: []
      parameters:
        - name: limit
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 200
            default: 50
          description: Page size (a value over the ceiling is capped, not refused).
        - name: cursor
          in: query
          required: false
          schema:
            type: string
          description: Opaque keyset cursor — the `next_cursor` of the previous page.
        - name: agent_id
          in: query
          required: false
          schema:
            type: string
            format: uuid
          description: Only the acknowledgements of this seat.
      responses:
        "200":
          description: One page of acknowledgements, newest first.
          content:
            application/json:
              schema:
                type: object
                required:
                  - acks
                  - next_cursor
                properties:
                  acks:
                    type: array
                    items:
                      $ref: "#/components/schemas/ScreenMonitorAck"
                  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"
  /v1/agent-monitoring-notice:
    get:
      operationId: getAgentMonitoringNotice
      tags:
        - identity
      summary: The current recording-and-supervision notice an agent accepts
      description: >
        The texts (one per language) of the CURRENT version of the notice an
        agent

        accepts at check-in (core#2323, MT-CLI-37; ADR #289). Where both parties
        must

        consent, the acceptance by agent and date is the proof the law asks for.
        The

        notice is a global catalogue (no tenant) and append-only: publishing a
        newer

        `version` invalidates, without touching them, every acceptance of the
        older

        ones. Core publishes NO legal text of its own: the catalogue is born
        empty and

        is filled by whoever migrates, with the reference of the text's
        approval.


        `404 not_found` while no version is in force. Reachable by a `dd_` key
        and by

        a `ddu_` login; a `ddw_` widget token is `403` before the handler.
      security:
        - tenantApiKey: []
        - userSessionToken: []
      responses:
        "200":
          description: The current version and its texts.
          content:
            application/json:
              schema:
                type: object
                required:
                  - notice
                properties:
                  notice:
                    $ref: "#/components/schemas/AgentMonitoringNotice"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
  /v1/agent-monitoring-acknowledgements:
    post:
      operationId: acknowledgeAgentMonitoringNotice
      tags:
        - identity
      summary: Accept the current recording-and-supervision notice from one's OWN seat
      description: >
        Records that the caller's OWN seat accepts `notice_version`, which must
        be the

        CURRENT version, as read in `lang` (a language the current version has a
        text

        in). The row keeps the version, the language, the hash of the text the
        agent

        saw, the date and the origin (`channel`). Idempotent: a second
        acceptance of the

        same version returns the first (`200`, no new row), even in another
        language.

        The ledger is append-only: it can neither be edited nor deleted (DELETE
        revoked

        and a trigger refuses UPDATE/DELETE); a newer notice version leaves the
        old

        acceptance on file and simply stops covering the agent.


        The seat is derived from the bearer, never from the body. Only a `ddu_`
        login

        with a linked seat may accept: a `dd_` machine key has no person behind
        it

        (`403 forbidden`), a `ddu_` with no active seat is `403
        seat_not_linked`, and an

        `agent_id` in the body that is not the caller's own seat is `403
        forbidden`. A

        `ddw_` widget token is `403` before the handler.


        `409 no_notice_published` when no version is in force and `409
        stale_notice`

        when `notice_version` is not the current one; `422` when the current
        version has

        no text in `lang`.
      security:
        - userSessionToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - notice_version
                - lang
              additionalProperties: false
              properties:
                notice_version:
                  type: integer
                  minimum: 1
                  description: The version being accepted; it must be the current one.
                lang:
                  type: string
                  description: The language of the text the agent was shown, e.g. `es` or `en`.
                agent_id:
                  type: string
                  format: uuid
                  description: Optional. If present it must be the caller's own seat; anything
                    else is `403`.
      responses:
        "200":
          description: The acceptance (the existing one when repeated).
          content:
            application/json:
              schema:
                type: object
                required:
                  - acknowledgement
                properties:
                  acknowledgement:
                    $ref: "#/components/schemas/AgentMonitoringAck"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "409":
          description: >
            `no_notice_published`: no notice version is in force, so nobody can
            accept.

            `stale_notice`: `notice_version` is not the current one.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: stale_notice
                  message: that notice version is not the current one
        "422":
          $ref: "#/components/responses/Unprocessable"
  /v1/agents/{id}/monitoring-acknowledgement:
    get:
      operationId: getAgentMonitoringAcknowledgement
      tags:
        - agents
      summary: The latest recording-and-supervision acceptance of one agent
      description: >
        The current notice version, whether THIS agent has accepted it
        (`current`) and

        the agent's latest acceptance of any version (`null` if none). A seat
        reads its

        own; a seat whose template role is `supervisor` or `admin` and a `dd_`
        tenant

        key read any agent of the tenant; another seat reading a colleague's is

        `403 forbidden`. An id from another tenant is `404`. A `ddw_` widget
        token is

        `403` before the handler.
      security:
        - tenantApiKey: []
        - userSessionToken: []
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: The agent's monitoring-notice state.
          content:
            application/json:
              schema:
                type: object
                required:
                  - monitoring
                properties:
                  monitoring:
                    $ref: "#/components/schemas/AgentMonitoringStatus"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
  /v1/screen-recordings/{id}/chunks/{n}:
    put:
      operationId: putScreenRecordingChunk
      tags:
        - recordings
      summary: Upload one chunk of the OWN seat's screen recording
      description: >
        Stores chunk `n` (from 0) of the screen recording `id` of one call. The

        browser's `MediaRecorder` sends the chunks as they are produced; `id` is
        a

        UUID the client chooses for the whole recording, so a retry is
        idempotent.


        Everything that identifies the recording comes from core, not from the

        client: the seat from the bearer, the call from `call_uuid` (which must
        be

        a call of THIS seat) and the retention from the voice recording of the
        same

        call (`retained_until`). The start and end instants are those of the
        call

        and are not stored again.


        The chunk is sealed with the tenant's key (AES-256-GCM) before it
        reaches

        the store, bound to `id` and `n`: the object cannot be opened elsewhere.


        Gates, in order: only a `ddu_` login with a linked seat (`403` for a
        `dd_`

        key, a `ddu_` with no seat, and a `ddw_` token); `404 not_found` while
        the

        tenant's flag `screen_monitor_enabled` is off; `404 not_found` for a

        `call_uuid` that is unknown or is another agent's call (the same answer,
        and

        an audit row records which); `403 screen_recording_not_acknowledged`
        when the

        seat has no current `screen_recording` acknowledgement (nothing is
        stored

        and a `not_armed` gap is recorded).


        Repeating a chunk with the same bytes answers `200 duplicate`; other
        bytes

        under the same `n` are `409 chunk_conflict`; a second recording id for
        the

        same call is `409 recording_conflict`. A chunk the store did not take is

        `503` and records an `upload_failed` gap.


        Limits: 4 000 000 bytes per chunk, `n` from 0 to 1 000 000.
      security:
        - userSessionToken: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
          description: Recording id, chosen by the client, one per call.
        - name: n
          in: path
          required: true
          schema:
            type: integer
            minimum: 0
            maximum: 1000000
          description: Chunk number, from 0.
        - name: call_uuid
          in: query
          required: true
          schema:
            type: string
            minLength: 1
            maxLength: 128
          description: The call being recorded; it must be the caller's own.
      requestBody:
        required: true
        content:
          video/webm:
            schema:
              type: string
              format: binary
          video/mp4:
            schema:
              type: string
              format: binary
      responses:
        "200":
          description: The chunk was already stored with the same bytes.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ScreenRecordingChunkAck"
        "201":
          description: The chunk was sealed and stored.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ScreenRecordingChunkAck"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: >
            `chunk_conflict`: that `n` already holds other bytes.
            `recording_conflict`:

            the call already has a recording under another id, or `id` belongs
            to

            another call.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: chunk_conflict
                  message: that chunk number already holds other bytes
        "410":
          description: The screen recording was purged at the end of its retention.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "413":
          description: The chunk is larger than 4 000 000 bytes.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "415":
          description: The body is not `video/webm` or `video/mp4`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "503":
          description: The store did not take the chunk; retry it.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
  /v1/screen-recordings/gaps:
    post:
      operationId: reportScreenRecordingGap
      tags:
        - recordings
      summary: Declare that a call's screen is missing, and why
      description: >
        The agent's client reports a gap it knows about: `agent_stopped` (the
        agent

        ended the capture), `upload_failed` (the chunks did not arrive) or

        `not_armed` (the capture was never started). Core writes its own
        `not_armed`

        gap when a chunk arrives without a current acknowledgement.


        Idempotent: one row per call, reason and source. The ledger is
        append-only.

        The same seat and call rules as the chunk upload apply (`404 not_found`
        for a

        call that is not the caller's; `404` while `screen_monitor_enabled` is
        off).

        No acknowledgement is needed: a gap is evidence, not content.
      security:
        - userSessionToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - call_uuid
                - reason
              additionalProperties: false
              properties:
                call_uuid:
                  type: string
                  minLength: 1
                  maxLength: 128
                reason:
                  type: string
                  enum:
                    - not_armed
                    - agent_stopped
                    - upload_failed
                note:
                  type: string
                  maxLength: 500
      responses:
        "200":
          description: The gap is on file (the first one when repeated).
          content:
            application/json:
              schema:
                type: object
                required:
                  - gap
                properties:
                  gap:
                    type: object
                    required:
                      - call_uuid
                      - reason
                      - source
                    properties:
                      call_uuid:
                        type: string
                      reason:
                        type: string
                        enum:
                          - not_armed
                          - agent_stopped
                          - upload_failed
                      source:
                        type: string
                        enum:
                          - agent
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
  /v1/screen-recordings/{id}/pii-marks:
    post:
      operationId: postScreenRecordingPiiMarks
      tags:
        - recordings
      summary: Declare which stretches of the OWN seat's screen recording showed PII
      description: >
        The agent's dashboard declares the intervals during which the screen
        showed a

        surface it paints as PII, and how far it has classified the video

        (`covered_through_ms`). Core keeps them append-only next to the
        recording; the

        redacted copy that supervisors watch by default is built from them
        (core#2017,

        ADR #266): every mark, widened by a guard on both sides, and everything
        after

        `covered_through_ms` is blacked out. A recording with no coverage row is
        blacked

        out entirely, so a client that never reports leaves a black copy, never
        a clear one.


        `zone` is a CLOSED catalogue: `consumer_card` (the consumer's record),

        `full_number` (the phone number unmasked), `payment` (card, bank
        account, SSN,

        PIN) and `focus_lost` (the dashboard has no focus or is not visible, so
        it does not

        know what the screen shows). A zone outside the catalogue is `422
        invalid_zone`; it

        is never stored as free text. A mark carries instants only, never text
        or values.


        Instants are milliseconds from the start of the recording's video, `0 <=
        start_ms <

        end_ms <= 86 400 000`. One request takes at most 200 marks and one
        recording holds at

        most 1 000 (`422 too_many_marks`, `422 mark_limit_reached`; a refused
        request stores

        nothing). `marks` may be empty: a request with only `covered_through_ms`
        is a

        heartbeat.


        Idempotent: a mark already on file (same `start_ms`, `end_ms`, `zone`)
        is counted in

        `duplicates` and not stored twice. `covered_through_ms` only moves
        forward: a lower

        value than the one on file is ignored, not an error (it is a retry that
        arrived late),

        and the response says the value in force.


        Same gates as the chunk upload, in order: only a `ddu_` login with a
        linked seat

        (`403` for a `dd_` key, a `ddu_` with no seat, and a `ddw_` token); `404
        not_found`

        while the tenant's flag `screen_monitor_enabled` is off; `404 not_found`
        for a

        recording that is unknown or belongs to another seat or tenant (one
        answer for all

        three; the recording exists once its first chunk landed, so a client
        that is ahead of

        its upload gets `404` and retries); `403
        screen_recording_not_acknowledged` when the

        seat has no current `screen_recording` acknowledgement; `410` once the
        recording was

        purged.
      security:
        - userSessionToken: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
          description: The recording id the client chose for the chunk upload.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - covered_through_ms
              additionalProperties: false
              properties:
                marks:
                  type: array
                  maxItems: 200
                  items:
                    type: object
                    required:
                      - start_ms
                      - end_ms
                      - zone
                    additionalProperties: false
                    properties:
                      start_ms:
                        type: integer
                        minimum: 0
                        maximum: 86400000
                      end_ms:
                        type: integer
                        minimum: 1
                        maximum: 86400000
                      zone:
                        type: string
                        enum:
                          - consumer_card
                          - full_number
                          - payment
                          - focus_lost
                covered_through_ms:
                  type: integer
                  minimum: 0
                  maximum: 86400000
      responses:
        "200":
          description: The marks are on file and the coverage is in force.
          content:
            application/json:
              schema:
                type: object
                required:
                  - pii_marks
                properties:
                  pii_marks:
                    type: object
                    required:
                      - recording_id
                      - stored
                      - duplicates
                      - covered_through_ms
                    properties:
                      recording_id:
                        type: string
                        format: uuid
                      stored:
                        type: integer
                        minimum: 0
                        description: Marks written by this request.
                      duplicates:
                        type: integer
                        minimum: 0
                        description: Marks already on file.
                      covered_through_ms:
                        type: integer
                        description: The value in force, the larger of the one on file and the one sent.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "410":
          description: The screen recording was purged at the end of its retention.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "422":
          description: >
            `invalid_zone`: the zone is not in the catalogue.
            `invalid_interval`: a mark

            outside `0 <= start_ms < end_ms <= 86 400 000`. `too_many_marks`:
            more than 200 in

            one request. `mark_limit_reached`: the recording would hold more
            than 1 000.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: invalid_zone
                  message: "zone must be one of: consumer_card, full_number, payment, focus_lost"
  /v1/screen-recordings/playback:
    post:
      operationId: playScreenRecording
      tags:
        - recordings
      summary: Ask to watch the REDACTED screen recording of a call; returns a
        short-lived signed URL
      description: >
        Takes a supervisor or admin seat (`403 role_forbidden` otherwise; a
        `dd_` key

        has no seat, so `403 forbidden`). It does NOT consult
        `screen_monitor_enabled`:

        switching the function off does not hide what was already recorded.


        What is served is the REDACTED copy: the video with every stretch the
        dashboard

        marked as PII (and every stretch it never classified) blacked out
        (core#2124,

        ADR #266). It is served only once the copy is `ready`. While it is being
        built, or

        rebuilt after a newer mark arrived, the answer is `409
        redaction_pending`; if it

        could not be built, `409 redaction_failed`. The original is NEVER served
        in its

        place: the unmasked video is a separate route, `POST

        /v1/screen-recordings/original-playback`.


        The access row (who, which `call_uuid`, which `variant`, until when) is
        written in

        the same transaction BEFORE the URL is minted; with no row there is no
        URL. The chunks

        are stored as ciphertext, so the URL is not a store URL: it points at

        `GET /v1/screen-media/{token}`, which needs NO bearer (the signed token
        in the

        path is the credential, valid for `expires_at`, 5 minutes) and so can be
        the

        `src` of a `<video>`. That route streams the decrypted chunks in order
        with the

        stored content type, `Cache-Control: no-store`; a token that is forged,

        expired or not backed by an access row is `404`, a recording purged
        since

        is `410`, and a token minted for the redacted copy whose copy stopped
        being

        servable since (a newer mark is being rebuilt) is `409`, never the
        original. It is not part of the public widget surface.


        `gaps` lists why the screen may be incomplete (`not_armed`,
        `agent_stopped`,

        `upload_failed`). A call with no recording is `404` (the gaps of a call
        that

        never got a chunk are not returned here); a purged one is `410` and
        writes no

        access row.
      security:
        - userSessionToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - call_uuid
              additionalProperties: false
              properties:
                call_uuid:
                  type: string
                  minLength: 1
                  maxLength: 128
      responses:
        "200":
          description: The access is on file and the URL is ready.
          content:
            application/json:
              schema:
                type: object
                required:
                  - playback
                properties:
                  playback:
                    type: object
                    required:
                      - recording_id
                      - call_uuid
                      - variant
                      - url
                      - expires_at
                      - retained_until
                      - chunks
                      - content_type
                      - gaps
                    properties:
                      recording_id:
                        type: string
                        format: uuid
                      call_uuid:
                        type: string
                      variant:
                        type: string
                        enum:
                          - redacted
                        description: Which video the URL serves.
                      url:
                        type: string
                        description: "Path under the API origin: /v1/screen-media/{token}."
                      expires_at:
                        type: string
                        format: date-time
                      retained_until:
                        type: string
                        format: date
                      chunks:
                        type: integer
                        minimum: 1
                      content_type:
                        type: string
                        enum:
                          - video/webm
                          - video/mp4
                      gaps:
                        type: array
                        items:
                          type: object
                          required:
                            - reason
                            - source
                            - note
                          properties:
                            reason:
                              type: string
                              enum:
                                - not_armed
                                - agent_stopped
                                - upload_failed
                            source:
                              type: string
                              enum:
                                - server
                                - agent
                            note:
                              type:
                                - string
                                - "null"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: >
            `redaction_pending`: the redacted copy is not ready yet (or is being
            rebuilt).

            `redaction_failed`: it could not be built. No access row is written
            and no URL is

            minted; the original is not served instead.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: redaction_pending
                  message: the redacted copy is not ready yet; the original is never served in its
                    place
        "410":
          description: The recording was purged.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
  /v1/screen-recordings/original-playback:
    post:
      operationId: playOriginalScreenRecording
      tags:
        - recordings
      summary: Ask to watch the UNREDACTED screen recording of a call, with a reason;
        returns a short-lived signed URL
      description: >
        The unmasked video, which can show a consumer's card or full number
        (core#2124,

        ADR #266). It takes a dashboard login (`ddu_`) with the `account_admin`
        role and

        nothing else: a `member`, a supervisor without that role, a `dd_` API
        key and any

        other principal get `403 role_forbidden`. It needs no agent seat and,
        like the

        redacted route, does not consult `screen_monitor_enabled`.


        `reason` is mandatory (a non-empty string of at most 500 characters,
        else `400`).

        The access row (the actor `user:<id>`, the `reason`, `variant:
        original`, the

        `call_uuid` and when it expires) is written in the same transaction
        BEFORE the URL

        is minted; with no row there is no URL. The URL is the same kind as the
        redacted

        one (`GET /v1/screen-media/{token}`, no bearer, 5 minutes,
        `Cache-Control:

        no-store`), but the access row it points at says `original`, so it
        streams the

        original chunks. A call with no recording is `404`; a purged one is
        `410` and

        writes no access row. The audio counterpart is `POST
        /v1/recordings/{id}/original-url`.
      security:
        - userSessionToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - call_uuid
                - reason
              additionalProperties: false
              properties:
                call_uuid:
                  type: string
                  minLength: 1
                  maxLength: 128
                reason:
                  type: string
                  minLength: 1
                  maxLength: 500
                  description: Why the original is needed; stored with the access.
      responses:
        "200":
          description: The access is on file and the URL is ready.
          content:
            application/json:
              schema:
                type: object
                required:
                  - playback
                properties:
                  playback:
                    type: object
                    required:
                      - recording_id
                      - call_uuid
                      - variant
                      - url
                      - expires_at
                      - retained_until
                      - chunks
                      - content_type
                      - gaps
                    properties:
                      recording_id:
                        type: string
                        format: uuid
                      call_uuid:
                        type: string
                      variant:
                        type: string
                        enum:
                          - original
                      url:
                        type: string
                        description: "Path under the API origin: /v1/screen-media/{token}."
                      expires_at:
                        type: string
                        format: date-time
                      retained_until:
                        type: string
                        format: date
                      chunks:
                        type: integer
                        minimum: 1
                      content_type:
                        type: string
                        enum:
                          - video/webm
                          - video/mp4
                      gaps:
                        type: array
                        items:
                          type: object
                          required:
                            - reason
                            - source
                            - note
                          properties:
                            reason:
                              type: string
                              enum:
                                - not_armed
                                - agent_stopped
                                - upload_failed
                            source:
                              type: string
                              enum:
                                - server
                                - agent
                            note:
                              type:
                                - string
                                - "null"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "410":
          description: The recording was purged.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
  /v1/screen-monitor/armed:
    put:
      operationId: setScreenMonitorArmed
      tags:
        - identity
      summary: Arm or disarm the screen capture held by one browser tab of one's OWN
        seat
      description: >
        The agent's dashboard calls this once it holds a retained full-screen

        capture (nothing is sent anywhere until a supervisor opens a session)
        and

        again, with `armed: false`, when it releases it. Only an armed seat can
        be

        watched. Arming needs the seat to be checked in (`409 not_checked_in`)
        and

        to hold an acknowledgement of the CURRENT notice for `live_monitoring`

        (`409 no_ack`).


        One tab per seat: arming with a different `tab_id` DISPLACES the
        previous

        one. The previous tab is told with a `screen.disarmed` frame

        (`reason: displaced`) on the seat's private topic, and a live session
        fed

        by it is closed as `ended` / `agent_stopped`. Arming again with the SAME

        `tab_id` changes nothing. Disarming with a `tab_id` that does not hold
        the

        arming is a no-op; the response says whether the seat is still armed.


        The armed state lives in the process, not in the ledger: a restart
        disarms

        everyone (fail-closed). A checkout also disarms the seat
        (`screen.disarmed`,

        `reason: checkout`), and so does losing its last socket.


        Only a `ddu_` login with a linked seat may call it (`dd_` key: `403`; a

        `ddw_` widget token is `403` before the handler). `404 not_found` while

        the tenant flag `screen_monitor_enabled` is off.
      security:
        - userSessionToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - armed
                - tab_id
              additionalProperties: false
              properties:
                armed:
                  type: boolean
                tab_id:
                  type: string
                  minLength: 1
                  maxLength: 64
                  description: An opaque id the dashboard gives to the tab holding the capture.
      responses:
        "200":
          description: The state of the seat after the call.
          content:
            application/json:
              schema:
                type: object
                required:
                  - armed
                properties:
                  armed:
                    type: boolean
                  tab_id:
                    type: string
                    description: Present when arming; the tab now holding the arming.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: >
            `not_checked_in`: the seat is not on shift. `no_ack`: no
            acknowledgement of

            the current notice for `live_monitoring`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: no_ack
                  message: acknowledge the current screen-monitor notice before arming
  /v1/screen-monitor/sessions:
    get:
      operationId: listScreenMonitorSessions
      tags:
        - identity
      summary: List the screen-monitor session ledger (supervisor or admin seat)
      description: >
        The tenant's monitoring sessions, newest first, paginated by cursor: the

        opened ones, the refused ones and the ones the system closed. Allowed
        only

        to a seat whose template role is `supervisor` or `admin` (`403

        role_forbidden` otherwise; a `dd_` key and a `ddu_` with no seat are
        `403`).

        The role is evaluated always; it does not depend on

        `supervision_role_enforced`.


        It does NOT consult `screen_monitor_enabled`: it is the evidence of who

        watched whom, and turning the function off neither deletes nor hides it.

        It never carries signalling payload, and nothing crosses tenants.


        A `started` row that is still live closes itself: when the agent or the

        supervisor loses the socket, checks out, or the flag, the role or the

        acknowledgement stop holding, the row becomes `ended` with the reason

        (`flag_off`, `role`, `no_ack`, `checkout`, `agent_stopped`,

        `credential_lapsed` or `peer_failed`).
      security:
        - userSessionToken: []
      parameters:
        - name: limit
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 200
            default: 50
          description: Page size (a value over the ceiling is capped, not refused).
        - name: cursor
          in: query
          required: false
          schema:
            type: string
          description: Opaque keyset cursor — the `next_cursor` of the previous page.
        - name: agent_id
          in: query
          required: false
          schema:
            type: string
            format: uuid
          description: Only the sessions over this seat.
        - name: result
          in: query
          required: false
          schema:
            type: string
            enum:
              - started
              - ended
              - failed
              - refused
          description: Only the rows with this result.
      responses:
        "200":
          description: One page of sessions, newest first.
          content:
            application/json:
              schema:
                type: object
                required:
                  - sessions
                  - next_cursor
                properties:
                  sessions:
                    type: array
                    items:
                      $ref: "#/components/schemas/ScreenMonitorSession"
                  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"
    post:
      operationId: openScreenMonitorSession
      tags:
        - identity
      summary: Ask to watch an agent's screen (supervisor or admin seat)
      description: >
        Opens a monitoring session over one agent. Core does not see the video:
        it

        validates, writes the ledger row and tells the agent
        (`screen.view_requested`

        on the agent's private topic); the browsers then negotiate the media
        peer to

        peer through `POST /v1/screen-monitor/sessions/{id}/signal`.


        EVIDENCE-FIRST: every outcome writes a row of `screen_monitor_sessions`,

        also the refused ones. The gates run in this order and the first one
        that

        fails is the row's `reason`: `role` (the caller's seat must be
        `supervisor`

        or `admin`; evaluated ALWAYS, it does not depend on

        `supervision_role_enforced`; a login with no seat is also `role`), then

        `agent_not_found`, `flag_off`, `no_ack` (the agent has no
        acknowledgement of

        the current notice for `live_monitoring`), `not_armed` and `agent_busy`
        (the

        agent already has a live session). An id of another tenant, an id that
        does

        not exist and an agent that is not active are `404` with the SAME body
        and

        leave the same row (`agent_not_found`, `agent_id` null): nothing here
        tells

        whether an agent exists in another tenant. `flag_off` is the same `404`
        as

        every other operation of the function.


        If the agent has no live socket to be told, the session is written as

        `failed` / `peer_failed` and the answer is `409 agent_unreachable`.


        Only a `ddu_` login may call it; a `dd_` key has no person behind it
        (`403

        forbidden`, no row). A `ddw_` is `403` before the handler.
      security:
        - userSessionToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - agent_id
              additionalProperties: false
              properties:
                agent_id:
                  type: string
                  format: uuid
                  description: The seat to watch.
      responses:
        "201":
          description: The session was opened; the row is `started`.
          content:
            application/json:
              schema:
                type: object
                required:
                  - session
                properties:
                  session:
                    $ref: "#/components/schemas/ScreenMonitorSession"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >
            `role_forbidden`: the caller's seat is not a supervisor or admin (or
            the

            login has no seat). `forbidden`: a `dd_` key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: role_forbidden
                  message: watching a screen takes a supervisor or admin seat
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: >
            `no_ack`, `not_armed`, `agent_busy`, or `agent_unreachable` (see
            above).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: not_armed
                  message: the agent is not sharing their screen
  /v1/screen-monitor/sessions/{id}/signal:
    post:
      operationId: signalScreenMonitorSession
      tags:
        - identity
      summary: Send one WebRTC signalling message to the other party of a live session
      description: >
        Forwards `payload` to the OTHER party of the session as a private frame

        (`screen.offer`, `screen.answer` or `screen.candidate`) and forgets it:
        the

        payload carries network addresses and is NEVER stored. The caller must
        be

        one of the two parties of a `started` session; `offer` can only come
        from

        the agent and `answer` only from the supervisor (`403`), `candidate`
        from

        either.


        A caller who is not a party, an id that does not exist in the tenant and
        an

        id that is not a UUID all answer the SAME `404`. A closed session is
        `409

        session_closed`.


        Bounded: the JSON of `payload` is at most 16384 bytes (`413

        payload_too_large`) and a session accepts at most 60 messages per 10
        seconds

        (`429 rate_limited` with `Retry-After`). `202` says the message was
        handed

        over; `delivered` is `false` when the other party had no live socket.


        `404 not_found` while the tenant flag `screen_monitor_enabled` is off.
      security:
        - userSessionToken: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - kind
                - payload
              additionalProperties: false
              properties:
                kind:
                  type: string
                  enum:
                    - offer
                    - answer
                    - candidate
                payload:
                  type: object
                  description: The SDP or ICE candidate object, passed through untouched.
      responses:
        "202":
          description: Handed over to the other party's private topic.
          content:
            application/json:
              schema:
                type: object
                required:
                  - delivered
                properties:
                  delivered:
                    type: boolean
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: "`session_closed`: the session is no longer `started`."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: session_closed
                  message: that session is already closed
        "413":
          description: "`payload_too_large`: the JSON of `payload` is over 16384 bytes."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: payload_too_large
                  message: payload is over 16384 bytes
        "429":
          description: "`rate_limited`: more than 60 messages in 10 seconds for this
            session."
          headers:
            Retry-After:
              $ref: "#/components/headers/RetryAfter"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: rate_limited
                  message: too many signalling messages for this session
  /v1/screen-monitor/sessions/{id}/ice:
    get:
      operationId: getScreenMonitorSessionIce
      tags:
        - identity
      summary: Ephemeral TURN credential for ONE monitoring session (either party)
      description: >
        The relay a browser needs to carry the screen video when a symmetric NAT

        blocks the direct path. Only the two parties of a session that is still

        `started` get it, with the tenant flag `screen_monitor_enabled` on. A

        caller who is not a party, an id that does not exist in the tenant and
        an

        id that is not a UUID all answer the SAME `404`.


        The credential is the cell's own coturn with its `use-auth-secret`
        scheme,

        the same shared secret as the softphone's (no new secret or variable):

        `username` is `<unix expiry>:scr.<session_id>.<agent|viewer>` (one `:`),

        `credential` is `Base64(HMAC-SHA1(secret, username))`, and it expires
        one

        hour after the call (`expires_at`). The shape is the WebRTC
        `RTCIceServer`

        one, to append to `iceServers` as is. The response is `Cache-Control:

        no-store`; the shared secret is never part of any response or log.


        Fail-closed: a cell with no TURN configured answers `503
        turn_unavailable`,

        never an ICE list that looks valid and carries no relay.
      security:
        - userSessionToken: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: The relay credential for the caller's side of the session.
          headers:
            Cache-Control:
              schema:
                type: string
                enum:
                  - no-store
          content:
            application/json:
              schema:
                type: object
                required:
                  - ice_server
                  - expires_at
                properties:
                  ice_server:
                    type: object
                    required:
                      - urls
                      - username
                      - credential
                    properties:
                      urls:
                        type: array
                        items:
                          type: string
                        description: "`turn:` / `turns:` URLs of the cell's relay."
                      username:
                        type: string
                        description: "`<unix expiry>:scr.<session_id>.<agent|viewer>`"
                      credential:
                        type: string
                        description: Base64 of HMAC-SHA1(shared secret, `username`).
                  expires_at:
                    type: string
                    format: date-time
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: "`session_closed`: the session is no longer `started`."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: session_closed
                  message: that session is already closed
        "503":
          description: "`turn_unavailable`: this cell has no TURN relay configured."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: turn_unavailable
                  message: this cell has no TURN relay configured for screen monitoring
  /v1/screen-monitor/sessions/{id}/end:
    post:
      operationId: endScreenMonitorSession
      tags:
        - identity
      summary: Close a monitoring session (either party)
      description: >
        Closes a `started` session. Without `reason` it is a normal close:
        `ended`,

        with `reason` `agent_stopped` when the agent asks and no reason when the

        supervisor does. `reason: peer_failed` reports that the media connection

        never came up: the row becomes `failed`. The other party gets a
        `screen.ended`

        frame on its private topic.


        Idempotent: closing a session that is already closed returns the row as
        it

        is and writes nothing. A caller who is not a party, an id that does not

        exist in the tenant and an id that is not a UUID all answer the SAME
        `404`.


        It does NOT consult the tenant flag: a session can always be closed.


        The system also closes a live session on its own, and tells both parties
        with

        `screen.ended` (`result`, `reason`): on the agent's checkout
        (`checkout`), when

        a party's last socket is lost (`agent_stopped` for the watched seat,

        `peer_failed` for the supervisor), when a socket's credential lapses

        (`credential_lapsed`), and on a socket's heartbeat when the flag, the

        supervisor's role or the agent's acknowledgement stopped holding
        (`flag_off`,

        `role`, `no_ack`).
      security:
        - userSessionToken: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              properties:
                reason:
                  type: string
                  enum:
                    - peer_failed
      responses:
        "200":
          description: The session after the call.
          content:
            application/json:
              schema:
                type: object
                required:
                  - session
                properties:
                  session:
                    $ref: "#/components/schemas/ScreenMonitorSession"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
  /v1/agent/queue:
    get:
      operationId: getAgentQueue
      tags:
        - agents
      summary: What the dialer has in front of the caller's OWN seat
      description: |
        A READ composed from four sources that already exist; there is no
        per-agent queue table and none is invented. For the seat the BEARER
        identifies — no parameter may name an agent, so another agent's queue
        cannot be read:

        - `presence`: the seat FSM state, the same as `GET /v1/agents`.
        - `live_call`: the seat's in-flight call (CDR), or `null`.
        - `callbacks`: pending callbacks the agent SCHEDULED, soonest first
          (`callbacks.agent_id` is the creator, not an assignment: the dialer
          does not assign callbacks per agent). At most 50.
        - `inbound_offers`: live inbound-queue offers addressed to this seat,
          derived from the durable queue stream; `[]` when the tenant's
          `inbound_queue_stream_enabled` flag is off.

        NOT present: a per-agent "preview pending" lead. The account a
        preview campaign offers to a seat is asked for, and reserved, with
        `POST /v1/agent/preview/next` (derived from an append-only stream, ADR
        #347), not read from this queue. Reserved/ringing calls appear as
        `presence`.

        Who gets a seat: a `ddu_` dashboard login with a linked seat. A `dd_`
        machine key has no person behind it and is `403`; a `ddu_` without an
        active seat is `403 seat_not_linked`; a `ddw_` widget token is `403`
        before the handler (the route is not on the widget allowlist).

        Deliberately absent (same projection contract as `/v1/debts`): phone
        numbers, the callback `note` and the attempt compliance snapshot — ids,
        timestamps and priority only.
      security:
        - userSessionToken: []
      responses:
        "200":
          description: The caller's own queue.
          content:
            application/json:
              schema:
                type: object
                required:
                  - queue
                properties:
                  queue:
                    $ref: "#/components/schemas/AgentQueue"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
  /v1/agent/preview/next:
    post:
      operationId: agentPreviewNext
      tags:
        - agents
      summary: The next account of a PREVIEW campaign for the caller's OWN seat
      description: |
        Asks the dialer for the next dialable account of a running `preview`
        campaign and RESERVES it for the caller's seat (core#2814, ADR #347).
        The seat comes from the bearer — no field names an agent — so the same
        identity contract as `GET /v1/agent/queue` applies: a `ddu_` login with
        a linked seat; a `dd_` machine key or a seatless `ddu_` is `403`; a
        `ddw_` is `403` before the handler.

        **Two seats never get the same account.** The reservation is not a
        column of the account: it is derived from the append-only
        `agent_preview_events` stream (the last fact of the account decides) and
        lapses by itself at `expires_at`; a lapsed offer is offered again. The
        call is idempotent: while the seat holds a live offer in the campaign,
        the same offer comes back with its original `expires_at`.

        `offer: null` means no account can be offered right now. The seat must
        be `available` (`409` `agent_not_available` with `current_state`). A
        campaign that is not `preview` is `422`, one that is not running `409`.

        No compliance gate runs here; every gate (DNC, quiet hours, consent,
        frequency, ...) is evaluated when the offer is dialed, fail-closed.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - campaign_id
              additionalProperties: false
              properties:
                campaign_id:
                  type: string
                  format: uuid
      responses:
        "200":
          description: The reserved offer, or `null` when nothing is offerable.
          content:
            application/json:
              schema:
                type: object
                required:
                  - offer
                properties:
                  offer:
                    oneOf:
                      - $ref: "#/components/schemas/PreviewOffer"
                      - type: "null"
        "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"
  /v1/agent/preview/dial:
    post:
      operationId: agentPreviewDial
      tags:
        - agents
      summary: Dial the offered account (preview) or a chosen one (manual)
      description: >
        Dials as the caller's OWN seat, through the SAME gated pipeline as

        `POST /v1/campaigns/{id}/dial` (`Runner.manual_dial/4`): seat,

        softphone check, caller-ID pool and the compliance gates, evaluated NOW

        and fail-closed. The outcomes are that verb's: `200` `dialing` with

        `call_id`; `200` `blocked` with the gate's `reason` (no call was placed

        and the account is parked for everyone for a while); `409`, `429`,
        `503`.


        Send exactly one of:


        - `offer_id` — a **preview** campaign: the offer `POST
          /v1/agent/preview/next` returned to THIS seat and still live. An offer
          of another seat, or an unknown one, is `404`; one that lapsed or was
          superseded is `409` `offer_expired`.
        - `debt_id` — a **manual** campaign: the account the agent picked from
          the campaign's list. If another seat holds it right now the answer is
          `409` `account_held`.

        Writing the `dialing` fact comes first: if it cannot be recorded the

        answer is `503` `audit_unavailable` and nothing is dialed. Every result

        leaves its own fact (`dialed`, `blocked`, `failed`).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - campaign_id
              additionalProperties: false
              properties:
                campaign_id:
                  type: string
                  format: uuid
                offer_id:
                  type: string
                  format: uuid
                debt_id:
                  type: string
                  format: uuid
      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"
        "503":
          description: No evidence, no call (`audit_unavailable`), or the softphone
            registration could not be verified (`registration_unverifiable`);
            nothing was dialed (retryable).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: audit_unavailable
                  message: "no evidence, no call: retry"
  /v1/agent/preview/skip:
    post:
      operationId: agentPreviewSkip
      tags:
        - agents
      summary: Skip the account offered to the caller's seat
      description: |
        Releases the offer and PARKS the account for everyone for a while (a
        skip is not a contact attempt). The skip is an append-only fact, so it
        leaves an auditable trace: who skipped which account, when, and the
        optional closed-vocabulary `reason` (free text is refused: it could
        carry personal data). An offer that is not live and the seat's own is
        `409` `offer_expired`; another seat's or an unknown one is `404`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - campaign_id
                - offer_id
              additionalProperties: false
              properties:
                campaign_id:
                  type: string
                  format: uuid
                offer_id:
                  type: string
                  format: uuid
                reason:
                  type: string
                  enum:
                    - not_now
                    - bad_data
                    - other
      responses:
        "200":
          description: The skip fact.
          content:
            application/json:
              schema:
                type: object
                required:
                  - skipped
                properties:
                  skipped:
                    $ref: "#/components/schemas/PreviewSkip"
        "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"
  /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. A tenant that has an ENABLED

        SAML IdP and the founder-governed flag `sso_required_enforced` on has

        closed this lane: proven-correct credentials of that tenant are a `403`

        `sso_required` (the way in is `POST /v1/auth/sso/start`). With the

        founder-governed flag `single_session_enforced` on, a login of ANY lane

        (password, second factor, forced enrollment, SSO) ends the person's
        other

        live `ddu_` sessions; the screen that held one gets `401
        session_superseded`

        on its next request.


        A tenant that published a login policy (allowed addresses and

        hours per role, ADR 227) in mode `enforce` refuses a sign-in from an
        address

        outside it or at an hour outside it with ONE `403`
        `login_policy_denied`, the

        same for the address and the hour: which of them failed is told only by
        the

        tenant's sign-in trail (`GET /v1/auth/sign-in-events`). Asked AFTER the

        credential proved right and before anything is issued. With the policy
        off or

        absent the sign-in is the one of before.
      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: >
            Either the session (`token` is the only time the plaintext is
            visible) or,

            when the account has a second factor active, `mfa_required: true`
            and a

            one-shot `challenge` to redeem at `POST /v1/auth/mfa/verify` — NO
            session

            exists yet and the challenge opens no other route. When the tenant
            REQUIRES

            a second factor (flag `mfa_required_enforced`, off by default) and
            the

            account has none yet, the answer is `mfa_required: true` with

            `purpose: enroll` and a challenge that only opens

            `POST /v1/auth/mfa/enroll` and `POST /v1/auth/mfa/enroll/confirm`.
          content:
            application/json:
              schema:
                oneOf:
                  - 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"
                  - $ref: "#/components/schemas/MfaChallenge"
                  - $ref: "#/components/schemas/MfaEnrollmentChallenge"
              examples:
                session:
                  summary: One-step login (no second factor)
                  value:
                    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
                mfa_required:
                  summary: Second factor active
                  value:
                    mfa_required: true
                    challenge: ddm_example_challenge_shown_once
                    expires_at: 2026-07-10T10:05:00Z
                    methods:
                      - totp
                      - recovery_code
                mfa_enrollment_required:
                  summary: Tenant requires a second factor and the account has none
                  value:
                    mfa_required: true
                    purpose: enroll
                    challenge: ddm_example_enroll_challenge_shown_once
                    expires_at: 2026-07-10T10:05:00Z
        "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.


        A session born from SSO whose identity provider published a

        `SingleLogoutService` (`slo_url` of the IdP configuration) answers `200`

        with `sso_logout_url`: the URL of the `LogoutRequest` (HTTP-Redirect,

        unsigned) the browser may follow to end the session at the IdP too. The

        local revocation has ALREADY happened and never depends on the IdP;
        every

        other session is `204`.
      security:
        - userSessionToken: []
      responses:
        "200":
          description: >
            The session is revoked and the IdP offers a Single Logout.
            `no-store`.
          content:
            application/json:
              schema:
                type: object
                required:
                  - sso_logout_url
                properties:
                  sso_logout_url:
                    type: string
                    format: uri
        "204":
          description: The session (if any) is revoked.
        "401":
          $ref: "#/components/responses/Unauthorized"
  /v1/auth/sign-in-events:
    get:
      operationId: listSignInEvents
      tags:
        - auth
      summary: Audit trail of the tenant's sign-ins
      description: >
        The append-only record of every sign-in attempt of the tenant's people —

        password, second factor and SSO; granted and refused — newest first,

        keyset-paginated. Each row carries the `method`, the `outcome`, a closed

        `reason` for a refusal, the caller's `ip` and `user_agent` (truncated to

        256 characters), and, for a grant that minted a session, its

        `session_id`. It never carries a password, a code or a token.


        `reason` is `null` on a grant, except `mfa_pending`: the password was

        right but the sign-in still owes the second factor (or its enrollment),

        so no session was minted; and `observed_ip_outside` /
        `observed_hours_outside`,

        written when the tenant's login policy is in mode `observe`: the sign-in

        was granted and the row says it would have been refused. On a refusal it
        is

        one of `bad_password`, `unknown_user`, `disabled`, `suspended`,

        `sso_required`, `mfa_failed`, `sso_rejected`, or, from the tenant's
        login

        policy, `ip_not_allowed` and `outside_hours` (mode `enforce`) and

        `policy_unavailable` (the policy could not be read or understood, in
        either

        mode: the sign-in is refused). An address that matched nobody in THIS
        tenant appears

        only as `email_attempted`, a sha256 digest of the normalized address
        taken with a fixed purpose prefix (not the bare sha256 of the address) —
        never

        the text.


        A grant is written in the same transaction that mints the session, so a

        session without its row cannot exist; a refusal is written right after

        and is best-effort. An SSO response whose signature does not verify is

        NOT recorded (the endpoint is anonymous); one that does, and is then

        refused, is. Rows are never edited or deleted.


        Filters: `user_id`, `outcome` (`success` | `failure`), `since`

        (inclusive) and `until` (exclusive), both RFC-3339 on `occurred_at`. A

        filter that does not parse is **400** naming the accepted values —

        never a silently empty page. Tenant-scoped: another tenant's rows are

        never visible, whatever the filter.


        **`account_admin` dashboard login only** (see `403`).
      security:
        - userSessionToken: []
      parameters:
        - name: user_id
          in: query
          required: false
          schema:
            type: string
            format: uuid
        - name: outcome
          in: query
          required: false
          schema:
            type: string
            enum:
              - success
              - failure
        - name: since
          in: query
          required: false
          schema:
            type: string
            format: date-time
          description: Only events at or after this instant.
        - name: until
          in: query
          required: false
          schema:
            type: string
            format: date-time
          description: Only events strictly before 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 sign-in events, newest first.
          content:
            application/json:
              schema:
                type: object
                required:
                  - events
                  - next_cursor
                properties:
                  events:
                    type: array
                    items:
                      $ref: "#/components/schemas/SignInEvent"
                  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":
          description: |
            `role_forbidden`: anything but a dashboard login (`ddu_`) whose
            person has the `account_admin` role — a `dd_` API key and a
            `member` login included; `forbidden`: a widget token (`ddw_`) or a
            suspended tenant.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: role_forbidden
                  message: the sign-in audit trail takes a dashboard login with the account_admin
                    role
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/support-access/grants:
    post:
      operationId: createSupportAccessGrant
      tags:
        - support-access
      summary: Let platform support in, with a reason and a fixed expiry
      description: |
        The customer's consent to platform-support access (ADR 225). An
        `account_admin` states a `reason` (a ticket or a motive, 1 to 500
        characters, no control characters) and, optionally, a
        `duration_minutes` (15 to 480, default 60). The grant expires at
        `created_at + duration_minutes` and that expiry NEVER moves: a longer
        access is a new grant.

        One grant is live per tenant at a time: while one is neither revoked nor
        expired the answer is **409** `grant_already_live`, naming it. A
        `grant_created` event is written in the same transaction.

        This route does not open anything by itself: a grant is the consent a
        support session needs, and the session lane is gated by the
        `support_access_enabled` flag, which is OFF in every cell. The route
        answers whatever the flag says, so a customer can always grant, revoke
        and read the trail.

        **`account_admin` dashboard login only** (see `403`). Platform staff
        have no route to write a grant.
      security:
        - userSessionToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required:
                - reason
              properties:
                reason:
                  type: string
                  minLength: 1
                  maxLength: 500
                  example: "Support ticket 4411: calls are not connecting"
                duration_minutes:
                  type: integer
                  minimum: 15
                  maximum: 480
                  default: 60
      responses:
        "201":
          description: The grant is live.
          content:
            application/json:
              schema:
                type: object
                required:
                  - grant
                properties:
                  grant:
                    $ref: "#/components/schemas/SupportAccessGrant"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: |
            `role_forbidden`: anything but a dashboard login (`ddu_`) whose
            person has the `account_admin` role — a `dd_` API key and a
            `member` login included; `forbidden`: a widget token (`ddw_`) or a
            suspended tenant.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: role_forbidden
                  message: granting platform-support access takes a dashboard login with the
                    account_admin role
        "409":
          description: |
            `grant_already_live`: the tenant already has a grant that is
            neither revoked nor expired; revoke it first.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: grant_already_live
                  message: a support-access grant is already live for this tenant; revoke it
                    before granting again
        "422":
          description: |
            `unprocessable`: the `reason` is missing, blank, over 500
            characters or has control characters, or `duration_minutes` is
            not an integer from 15 to 480. Nothing is written.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: unprocessable
                  message: duration_minutes must be an integer from 15 to 480
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/support-access/grants/{id}:
    delete:
      operationId: revokeSupportAccessGrant
      tags:
        - support-access
      summary: End the consent at once
      description: |
        Revokes the grant `id`. The row is kept as evidence: it gains
        `revoked_at` and `revoked_by`, once, and a `grant_revoked` event is
        written in the same transaction. A grant that expired by itself can
        still be revoked. A revoked grant is **409** `grant_already_revoked`.

        An unknown id, a malformed one and another tenant's grant are the same
        **404**.

        **`account_admin` dashboard login only** (see `403`).
      security:
        - userSessionToken: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: The grant, now revoked.
          content:
            application/json:
              schema:
                type: object
                required:
                  - grant
                properties:
                  grant:
                    $ref: "#/components/schemas/SupportAccessGrant"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: |
            `role_forbidden`: anything but a dashboard login (`ddu_`) whose
            person has the `account_admin` role.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: role_forbidden
                  message: revoking platform-support access takes a dashboard login with the
                    account_admin role
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: "`grant_already_revoked`: the grant was revoked before."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: grant_already_revoked
                  message: this grant was already revoked
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/support-access/events:
    get:
      operationId: listSupportAccessEvents
      tags:
        - support-access
      summary: The trail of platform-support access, newest first
      description: |
        The append-only record of the consent and of everything support did
        under it, keyset-paginated, newest first. `event` is one of
        `grant_created`, `grant_revoked`, `session_opened`, `session_revoked`,
        `request` and `request_denied`. A `request` row names the route
        PATTERN and the id in the path, never a body, a query string or a
        response. Rows are never edited or deleted.

        Filters: `event`, `grant_id`, `since` (inclusive) and `until`
        (exclusive), both RFC-3339 on `occurred_at`. A filter that does not
        parse is **400** naming the accepted values — never a silently empty
        page. Tenant-scoped: another tenant's rows are never visible,
        whatever the filter.

        **`account_admin` dashboard login only** (see `403`).
      security:
        - userSessionToken: []
      parameters:
        - name: event
          in: query
          required: false
          schema:
            $ref: "#/components/schemas/SupportAccessEventKind"
        - name: grant_id
          in: query
          required: false
          schema:
            type: string
            format: uuid
        - name: since
          in: query
          required: false
          schema:
            type: string
            format: date-time
          description: Only events at or after this instant.
        - name: until
          in: query
          required: false
          schema:
            type: string
            format: date-time
          description: Only events strictly before 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 the trail, newest first.
          content:
            application/json:
              schema:
                type: object
                required:
                  - events
                  - next_cursor
                properties:
                  events:
                    type: array
                    items:
                      $ref: "#/components/schemas/SupportAccessEvent"
                  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":
          description: |
            `role_forbidden`: anything but a dashboard login (`ddu_`) whose
            person has the `account_admin` role.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: role_forbidden
                  message: the support-access trail takes a dashboard login with the account_admin
                    role
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/support-access/active:
    get:
      operationId: getSupportAccessActive
      tags:
        - support-access
      summary: Whether a support session is open right now (the banner)
      description: |
        What the dashboard shows every person of the tenant: is someone from
        platform support inside the account at this moment, until when, and
        who. `active` is `false` when the `support_access_enabled` flag is off
        for the tenant, when there is no live grant, or when no session of it is
        open and unexpired. A session is open from its `session_opened` event
        until a `session_revoked` one, for at most 60 minutes and never past
        the grant's expiry. The support session itself arrives with a later
        change: until then this answers `active: false` for every tenant.

        Any dashboard login (`ddu_`) of the tenant, `member` included. An API
        key (`dd_`) is **403**.
      security:
        - userSessionToken: []
      responses:
        "200":
          description: The banner's answer.
          content:
            application/json:
              schema:
                type: object
                required:
                  - active
                  - expires_at
                  - operator_name
                properties:
                  active:
                    type: boolean
                  expires_at:
                    oneOf:
                      - type: string
                        format: date-time
                      - type: "null"
                    description: When the open session ends; `null` when not active.
                  operator_name:
                    oneOf:
                      - type: string
                      - type: "null"
                    description: The platform person's name; `null` when not active or unnamed.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: "`role_forbidden`: a `dd_` API key; `forbidden`: a widget token or
            a suspended tenant."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: role_forbidden
                  message: the support-access banner takes a dashboard login
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/auth/mfa/verify:
    post:
      operationId: verifyMfaLogin
      tags:
        - auth
      summary: Second step of a login with a second factor
      description: >
        Redeems the `challenge` returned by `POST /v1/auth/login` with a TOTP
        `code`

        or ONE single-use `recovery_code` (exactly one of the two) and mints the

        `ddu_` session the first step withheld. The response body is the same as
        a

        one-step login.


        The challenge is single-use and dies after 5 minutes, after 5 wrong
        codes, or

        when the person's password changes in between; an unknown, expired,
        spent or

        out-of-attempts challenge is ONE identical `401`. A TOTP code is
        accepted at

        most once (a replay of the same code is refused) and a recovery code is

        burned by its first use. Wrong codes are rate-limited per USER (`429`).


        A tenant that published a login policy (allowed addresses and

        hours per role, ADR 227) in mode `enforce` refuses a sign-in from an
        address

        outside it or at an hour outside it with ONE `403`
        `login_policy_denied`, the

        same for the address and the hour: which of them failed is told only by
        the

        tenant's sign-in trail (`GET /v1/auth/sign-in-events`). Asked AFTER the

        credential proved right and before anything is issued. With the policy
        off or

        absent the sign-in is the one of before. It is asked again here, before

        the code is compared, so a refusal spends neither the challenge nor an
        attempt.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - challenge
              properties:
                challenge:
                  type: string
                  description: The `ddm_` challenge from the login.
                code:
                  type: string
                  description: TOTP code, 6 digits.
                  example: "492039"
                recovery_code:
                  type: string
                  description: One recovery code, `XXXX-XXXX-XXXX-XXXX`.
              oneOf:
                - required:
                    - code
                - required:
                    - recovery_code
      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.
                  session:
                    $ref: "#/components/schemas/UserSession"
                  user:
                    $ref: "#/components/schemas/User"
                  tenant:
                    $ref: "#/components/schemas/Tenant"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          description: Wrong code, or an unknown/expired/spent challenge.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: unauthorized
                  message: invalid code
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/MfaVerifyThrottled"
  /v1/auth/mfa/enroll:
    post:
      operationId: startForcedMfaEnrollment
      tags:
        - auth
      summary: Start the second-factor enrollment a tenant requires at login
      description: >
        First half of the FORCED enrollment: presents the `challenge` of purpose

        `enroll` that `POST /v1/auth/login` returned (`mfa_required: true`,

        `purpose: enroll`) and gets a fresh TOTP secret, left `pending` until

        `POST /v1/auth/mfa/enroll/confirm`. No password is asked again: the

        challenge exists only because the right password was just proven.


        Starting spends no attempt of the challenge; repeating it replaces the

        pending secret. The secret is shown exactly once. A challenge of purpose

        `login` (and an unknown, expired, spent or out-of-attempts one) is ONE

        identical `401`. An account that already has an active factor is `409`.

        Rate-limited per USER with the same budget as `POST
        /v1/auth/mfa/verify`.


        A tenant that published a login policy (allowed addresses and

        hours per role, ADR 227) in mode `enforce` refuses a sign-in from an
        address

        outside it or at an hour outside it with ONE `403`
        `login_policy_denied`, the

        same for the address and the hour: which of them failed is told only by
        the

        tenant's sign-in trail (`GET /v1/auth/sign-in-events`). Asked AFTER the

        credential proved right and before anything is issued. With the policy
        off or

        absent the sign-in is the one of before. The secret is not handed out

        to a refused address or hour.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - challenge
              properties:
                challenge:
                  type: string
                  description: The `ddm_` enroll challenge from the login.
      responses:
        "200":
          description: Pending secret; encode `otpauth_uri` as the QR.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MfaEnrollment"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          description: Unknown, expired, spent, out-of-attempts or wrong-purpose challenge.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: unauthorized
                  message: invalid or expired challenge
        "403":
          $ref: "#/components/responses/Forbidden"
        "409":
          $ref: "#/components/responses/Conflict"
        "429":
          $ref: "#/components/responses/MfaVerifyThrottled"
  /v1/auth/mfa/enroll/confirm:
    post:
      operationId: confirmForcedMfaEnrollment
      tags:
        - auth
      summary: Confirm the forced enrollment and finish the login
      description: >
        Second half of the FORCED enrollment: the first TOTP `code` of the
        pending

        secret activates the factor, issues the recovery codes, consumes the

        challenge and mints the `ddu_` session the login withheld — all in one

        transaction. The body is the login's session body plus `recovery_codes`,

        shown in clear exactly once.


        The challenge dies after 5 minutes, after 5 wrong codes, or when the

        person's password changes in between. A wrong code is `401 invalid code`

        and counts against the per-USER budget (`429`); an unknown, expired,
        spent

        or wrong-purpose challenge is `401 invalid or expired challenge`. `409`

        when no enrollment was started.


        A tenant that published a login policy (allowed addresses and

        hours per role, ADR 227) in mode `enforce` refuses a sign-in from an
        address

        outside it or at an hour outside it with ONE `403`
        `login_policy_denied`, the

        same for the address and the hour: which of them failed is told only by
        the

        tenant's sign-in trail (`GET /v1/auth/sign-in-events`). Asked AFTER the

        credential proved right and before anything is issued. With the policy
        off or

        absent the sign-in is the one of before. A refusal spends neither the

        challenge nor an attempt.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - challenge
                - code
              properties:
                challenge:
                  type: string
                  description: The `ddm_` enroll challenge from the login.
                code:
                  type: string
                  description: TOTP code, 6 digits.
                  example: "492039"
      responses:
        "200":
          description: Factor active, session minted; `token` is the only time the
            plaintext is visible.
          content:
            application/json:
              schema:
                type: object
                required:
                  - token
                  - session
                  - user
                  - tenant
                  - recovery_codes
                properties:
                  token:
                    type: string
                    description: Plaintext ddu_ bearer, shown exactly once.
                  session:
                    $ref: "#/components/schemas/UserSession"
                  user:
                    $ref: "#/components/schemas/User"
                  tenant:
                    $ref: "#/components/schemas/Tenant"
                  recovery_codes:
                    type: array
                    description: Single-use codes, shown in clear exactly once.
                    items:
                      type: string
                      example: ABCD-EFGH-IJKL-MNOP
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          description: Wrong code, or an unknown/expired/spent/wrong-purpose challenge.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: unauthorized
                  message: invalid code
        "403":
          $ref: "#/components/responses/Forbidden"
        "409":
          $ref: "#/components/responses/Conflict"
        "429":
          $ref: "#/components/responses/MfaVerifyThrottled"
  /v1/phone_verification:
    get:
      operationId: getPhoneVerification
      tags:
        - auth
      summary: Is the account's phone verified
      description: >
        Whether the tenant has a verified phone and when. A public sign-up
        account that has not verified

        a phone does not rise above the lowest trust level (see the trust
        levels).
      responses:
        "200":
          description: The verification state.
          content:
            application/json:
              schema:
                type: object
                required:
                  - verified
                  - verified_at
                properties:
                  verified:
                    type: boolean
                  verified_at:
                    type:
                      - string
                      - "null"
                    format: date-time
                additionalProperties: false
              example:
                verified: true
                verified_at: 2026-11-06T12:00:00Z
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
    post:
      operationId: sendPhoneVerificationCode
      tags:
        - auth
      summary: Send a verification code by SMS
      description: >
        Sends a six-digit code by SMS, from the platform, to a number that
        belongs to no tenant. The code

        expires after `expires_in` seconds and a new request supersedes the
        previous code. The cost is the

        platform's and is capped on its own (per number, per origin and per day,
        plus a daily platform cap):

        it never touches the tenant's balance. Number ranges of high cost
        (premium-rate, shared and

        satellite networks, and the NANP area codes most used in traffic-pumping
        fraud) are refused from the

        first send. `502 delivery_failed` means the code never left and cannot
        be redeemed.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - phone
              properties:
                phone:
                  type: string
                  description: E.164, e.g. +15551234567.
              additionalProperties: false
            example:
              phone: "+15551234567"
      responses:
        "202":
          description: The code was handed to the carrier.
          content:
            application/json:
              schema:
                type: object
                required:
                  - status
                  - expires_in
                properties:
                  status:
                    type: string
                    enum:
                      - sent
                  expires_in:
                    type: integer
                additionalProperties: false
              example:
                status: sent
                expires_in: 600
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "409":
          description: The tenant already verified a phone (`phone_already_verified`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: phone_already_verified
                  message: the phone is already verified
        "422":
          description: Not an E.164 number (`invalid_phone`) or a refused range
            (`phone_range_blocked`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: invalid_phone
                  message: phone must be an E.164 number (+15551234567)
        "429":
          description: A send cap is spent (`phone_verification_rate_limited`).
          headers:
            Retry-After:
              $ref: "#/components/headers/RetryAfter"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: phone_verification_rate_limited
                  message: too many verification codes requested; retry tomorrow
        "502":
          description: The code could not be delivered (`delivery_failed`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: delivery_failed
                  message: the code could not be delivered
        "503":
          description: The cell has no SMS channel configured
            (`phone_verification_unavailable`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: phone_verification_unavailable
                  message: phone verification is not available
  /v1/phone_verification/check:
    post:
      operationId: checkPhoneVerificationCode
      tags:
        - auth
      summary: Check a verification code
      description: >
        Checks the code against the LAST one sent. It works once, until it
        expires and for a bounded number

        of attempts. A wrong, expired, superseded or already used code answers
        the SAME `422 invalid_code`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - code
              properties:
                code:
                  type: string
              additionalProperties: false
            example:
              code: "123456"
      responses:
        "200":
          description: The phone is verified.
          content:
            application/json:
              schema:
                type: object
                required:
                  - verified
                properties:
                  verified:
                    type: boolean
                    enum:
                      - true
                additionalProperties: false
              example:
                verified: true
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "422":
          description: The code is wrong, expired or already used (`invalid_code`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: invalid_code
                  message: the code is wrong, expired or already used
  /v1/signups/config:
    get:
      operationId: getSignupConfig
      tags:
        - auth
      summary: What the public sign-up form needs
      description: >
        Anonymous. Served on the PUBLIC listener. Returns the terms version in
        force

        (the one `POST /v1/signups` must echo back) and the PUBLIC site key of
        the

        anti-bot widget (Cloudflare Turnstile). `available` is `false` while the
        cell

        lacks any of terms version, anti-bot site key or the console link base:
        the

        form should say sign-up is closed rather than let the person fill it in.

        It is a read of configuration; it does not say the anti-bot provider
        answers.
      security: []
      responses:
        "200":
          description: The form configuration.
          content:
            application/json:
              schema:
                type: object
                required:
                  - available
                  - terms_version
                  - captcha
                properties:
                  available:
                    type: boolean
                  terms_version:
                    type:
                      - string
                      - "null"
                  captcha:
                    type: object
                    required:
                      - provider
                      - site_key
                    properties:
                      provider:
                        type: string
                        enum:
                          - turnstile
                      site_key:
                        type:
                          - string
                          - "null"
                    additionalProperties: false
                additionalProperties: false
              example:
                available: true
                terms_version: 2026-11
                captcha:
                  provider: turnstile
                  site_key: "0x4AAAAAAA"
  /v1/signups:
    post:
      operationId: createSignup
      tags:
        - auth
      summary: Ask for the registration of a developer account
      description: >
        Anonymous. Served on the PUBLIC listener. Creates a tenant of the
        `developer`

        billing scheme that is NOT active (its plan stays `draft`, so anything
        that

        spends or calls answers `402 plan_inactive`), records the acceptance of
        the

        terms version (append-only: version, instant, origin) and mails a
        single-use

        link to the address. Redeeming that link sets the person's password and

        verifies the address in the same step. **Until then there is no key, no

        person and no session**, and there is no setting that changes that.


        The answer is `202` with the SAME body whether the address is new,
        already has

        a pending registration (a new link is sent; the previous one dies) or
        already

        belongs to a person (a «you already have an account» notice goes out,
        with no

        link). Nothing in the body or the status says which.


        Checks run in this order and stop at the first failure: cell
        availability

        (`503 signup_unavailable`), body shape (`400`), terms version

        (`422 terms_version_mismatch`), anti-bot (`403 captcha_failed`;

        `503 captcha_unavailable` when the provider does not answer — it fails

        CLOSED), per-origin rate (`429 signup_rate_limited`), per-address rate

        (`429 signup_rate_limited`: a second registration of the same address
        inside

        the window).
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - email
                - name
                - terms_version
                - captcha_token
              properties:
                email:
                  type: string
                  format: email
                name:
                  type: string
                  minLength: 1
                  maxLength: 200
                terms_version:
                  type: string
                  description: Must equal `terms_version` of `GET /v1/signups/config`.
                captcha_token:
                  type: string
                  description: The token the Turnstile widget produced. Never stored.
                locale:
                  type: string
                  enum:
                    - es
                    - en
                  description: Language of the mail. Absent or anything else - the cell's
                    language.
              additionalProperties: false
            example:
              email: dev@example.com
              name: Ada Developer
              terms_version: 2026-11
              captcha_token: XXXX.DUMMY.TOKEN.XXXX
      responses:
        "202":
          description: Accepted. Identical for every outcome (see the description).
          content:
            application/json:
              schema:
                type: object
                required:
                  - status
                properties:
                  status:
                    type: string
                    enum:
                      - accepted
                additionalProperties: false
              example:
                status: accepted
        "400":
          $ref: "#/components/responses/BadRequest"
        "403":
          description: The anti-bot provider rejected the token (`code` `captcha_failed`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: captcha_failed
                  message: the anti-bot check was not passed
        "422":
          description: The terms version is not the one in force (`code`
            `terms_version_mismatch`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: terms_version_mismatch
                  message: terms_version must be the current version (see GET /v1/signups/config)
        "429":
          description: |
            The origin or the address is over its sign-up rate (`code`
            `signup_rate_limited`). Wait `Retry-After` seconds.
          headers:
            Retry-After:
              $ref: "#/components/headers/RetryAfter"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: signup_rate_limited
                  message: too many sign-up requests; retry later
        "503":
          description: >
            Sign-up is closed in this cell (`signup_unavailable`) or the
            anti-bot

            provider did not answer (`captcha_unavailable`). Nothing was
            created.
          headers:
            Retry-After:
              $ref: "#/components/headers/RetryAfter"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: captcha_unavailable
                  message: the anti-bot check is unavailable; retry later
  /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
                locale:
                  type: string
                  enum:
                    - es
                    - en
                  description: >
                    Language the person has the console in; the mail goes out in
                    it.

                    Absent or anything else: the cell's language
                    (`DEFAULT_LOCALE`).

                    Never changes the answer — the `202` is the same byte for
                    byte.
              additionalProperties: false
            example:
              email: admin@local.test
              locale: es
      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 or invitation link and set the 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.

        The token may also come from an INVITATION (`POST /v1/users`, the
        `/accept-invite` link; ADR #182): then this call CREATES the person, in
        the inviting tenant, with this password, and burns the invitation.

        Fail-closed and single-valued: an unknown, tampered, expired,
        superseded or already-redeemed token — and an invitation whose address
        became a user anywhere in the meantime — are ONE identical `401`. The
        password policy (12–128 characters, the same one every dashboard
        password follows) 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 (or, for an invitation, the person created), 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,
            or an invitation whose address is taken — indistinguishable by
            design.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: unauthorized
                  message: invalid or expired token
        "422":
          description: >
            The new password is refused and NOTHING changed (the link stays
            redeemable):

            `unprocessable` when it does not meet the length policy, or

            `password_blocklisted` (core#2335, NIST SP 800-63B-4 §3.1.1.2) when
            it is on

            the local blocklist of commonly used or compromised passwords,
            contains the

            person's e-mail or the workspace's name, or is trivially repetitive.
            The

            message names the reason (`common`, `repetitive`, `contains_email`,

            `contains_tenant`). Existing passwords are never checked.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: password_blocklisted
                  message: "password is on the list of commonly used or compromised passwords
                    (reason: common)"
  /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, a tenant without the widget lane, unknown email, wrong password

        and a login-not-enabled agent are ONE identical `401` at the same PBKDF2

        cost, and none of them mints a token; only proven-correct credentials of

        a suspended tenant/account earn the `403`. Per-(tenant, email)

        fixed-window brake. Flag-gated on the widget lane (governed key

        `widget_auth_enabled`, per tenant under a cell ceiling): while the

        cell's ceiling is closed, 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.


        The door is not softer than the console's (core#2406, ADR #227 addendum;

        tenant flag `widget_login_honors_policy`, ON by default). Asked AFTER
        the

        password proved right and before anything is minted: the tenant's login

        policy (address and hours) — refused is `403` `login_policy_denied`, the

        same body as the console's, and the same question is asked again on
        every

        later `ddw_` request, so a live token that moves to a refused address

        stops working there. A seat linked to a person is judged with that

        person's role; a seat with no person with the role `agent` (it is bound
        by

        the address list, and by no hours window, which belong to a person).
        And,

        in a tenant with `mfa_required_enforced` ON, the second factor: a seat

        linked to a person must send that person's current TOTP as `mfa_code`

        (a wrong one is the same `401` as a wrong password and counts in the

        brake); no `mfa_code`, a seat with no person, or a person with no active

        factor is `403` `mfa_required` — a widget cannot enroll one. With the
        flag

        OFF, or a tenant with no policy and no second factor, the login is the
        one

        of before.
      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
                mfa_code:
                  type: string
                  description: The current TOTP of the person linked to the seat. Needed only when
                    the tenant has `mfa_required_enforced` ON.
            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, a tenant without the widget lane 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":
          description: >
            The tenant is suspended (`forbidden`); or the right credentials were
            refused: `login_policy_denied`

            (the tenant's login policy refuses this address or hour, the same
            body as the console's) or

            `mfa_required` (the tenant requires a second factor and the seat
            cannot present one).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: mfa_required
                  message: this workspace requires a second factor for this sign-in
        "404":
          description: The widget lane's cell ceiling is closed (`WIDGET_AUTH_ENABLED` off
            on this deployment).
        "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`, and a tenant without the widget lane, answer
        `origins: []` — no tenant-existence oracle. Flag-gated on the widget
        lane (governed key `widget_auth_enabled`): while the cell's ceiling is
        closed, 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's cell ceiling is closed (`WIDGET_AUTH_ENABLED` off
            on this deployment).
  /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 (`unprocessable`), or the new one
            is on the

            local blocklist of commonly used or compromised passwords, contains
            the

            person's e-mail or the workspace's name, or is trivially repetitive

            (`password_blocklisted`, core#2335, NIST SP 800-63B-4 §3.1.1.2; the
            message

            names the reason). Nothing changed, and a blocklisted new password
            does not

            spend the wrong-confirmation budget.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: unprocessable
                  message: current password is incorrect
        "429":
          $ref: "#/components/responses/PasswordConfirmThrottled"
  /v1/me/mfa:
    get:
      operationId: getOwnMfa
      tags:
        - auth
      summary: The caller's own second-factor state
      description: Requires a `ddu_` login session (`403` for a `dd_` key).
      responses:
        "200":
          description: State.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MfaStatus"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
  /v1/me/mfa/enroll:
    post:
      operationId: enrollOwnMfa
      tags:
        - auth
      summary: Start TOTP enrollment
      description: >
        Issues a fresh TOTP secret, PENDING until `POST /v1/me/mfa/confirm`
        proves the

        authenticator holds it. The secret and its `otpauth://` URI (the QR
        payload)

        are shown exactly once; the secret is stored encrypted. Repeating the
        call

        before confirming replaces the pending secret. With a factor already
        active

        it is `409`: remove it first. Requires the current `password` (there is
        no

        factor yet to ask for), so a stolen session alone cannot lock the holder
        out

        with a factor of its own; a wrong password is rate-limited per USER
        (`429`).

        Requires a `ddu_` session.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - password
              properties:
                password:
                  type: string
                  format: password
      responses:
        "200":
          description: Secret and URI, shown once.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MfaEnrollment"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "409":
          description: A second factor is already active.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: conflict
                  message: a second factor is already active; remove it first
        "422":
          description: The password is incorrect.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: unprocessable
                  message: the password is incorrect
        "429":
          $ref: "#/components/responses/MfaVerifyThrottled"
  /v1/me/mfa/confirm:
    post:
      operationId: confirmOwnMfa
      tags:
        - auth
      summary: Confirm enrollment with the first code
      description: >
        Activates the pending factor with a valid TOTP code and returns the
        recovery

        codes, in clear, exactly once (only their sha256 is stored). Wrong codes
        are

        rate-limited per USER (`429`). Requires a `ddu_` session.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - code
              properties:
                code:
                  type: string
                  example: "492039"
      responses:
        "200":
          description: Factor active; recovery codes shown once.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RecoveryCodes"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "409":
          description: No pending enrollment.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "422":
          description: The code is incorrect.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: unprocessable
                  message: the code is incorrect
        "429":
          $ref: "#/components/responses/MfaVerifyThrottled"
  /v1/me/mfa/disable:
    post:
      operationId: disableOwnMfa
      tags:
        - auth
      summary: Remove the second factor
      description: >
        Reauthentication required: the current password AND a second factor (a
        TOTP

        `code` or a `recovery_code`, exactly one). Revokes the unused recovery
        codes.

        Wrong credentials are rate-limited per USER (`429`). Requires a `ddu_`
        session.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/MfaReauth"
      responses:
        "204":
          description: Second factor removed.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "409":
          description: No active second factor.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "422":
          description: The password or the code is incorrect.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "429":
          $ref: "#/components/responses/MfaVerifyThrottled"
  /v1/me/mfa/recovery-codes:
    post:
      operationId: regenerateOwnRecoveryCodes
      tags:
        - auth
      summary: Regenerate the recovery codes
      description: >
        Same reauthentication as `disable`. Revokes every unused code and issues
        a

        new set, shown in clear exactly once. Requires a `ddu_` session.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/MfaReauth"
      responses:
        "200":
          description: New codes, shown once.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RecoveryCodes"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "409":
          description: No active second factor.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "422":
          description: The password or the code is incorrect.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "429":
          $ref: "#/components/responses/MfaVerifyThrottled"
  /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"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/tenant/calling-window:
    get:
      operationId: getTenantCallingWindow
      tags:
        - identity
      summary: The tenant's own calling hours
      description: |
        The tenant-wide calling window, or `null` when the tenant has none (the
        dial path is then what it was before this key existed). It only NARROWS:
        a call leaves when the legal window (R18, per destination), the
        campaign's own window AND this one are all open. Readable by a `dd_` key
        and by any dashboard login.
      responses:
        "200":
          description: The stored window, or `null`.
          content:
            application/json:
              schema:
                type: object
                required:
                  - calling_window
                properties:
                  calling_window:
                    oneOf:
                      - $ref: "#/components/schemas/TenantCallingWindow"
                      - type: "null"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
        "503":
          description: The stored window could not be read (never answered as "no window").
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
    put:
      operationId: putTenantCallingWindow
      tags:
        - identity
      summary: Set the tenant's calling hours
      description: >
        Replaces the tenant's window. **Tenant management only**: a `dd_` key or
        an

        `account_admin` login; a `member` login is `403 role_forbidden`. The
        body

        is validated before anything is stored: a malformed window is `422` with

        the reason and the previous window stays. The stored value is the

        normalised one (`HH:MM` becomes `HH:MM:SS`). The write is audited.


        A stored value that can no longer be evaluated REFUSES the dial

        (`rule_id` `tenant.calling_window_unavailable.v1`): only the absence of
        a

        window opens.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/TenantCallingWindow"
            example:
              timezone: America/Chicago
              weekly:
                mon:
                  start: 09:00
                  end: 20:00
                tue:
                  start: 09:00
                  end: 20:00
      responses:
        "200":
          description: Stored (normalised).
          content:
            application/json:
              schema:
                type: object
                required:
                  - calling_window
                properties:
                  calling_window:
                    $ref: "#/components/schemas/TenantCallingWindow"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >
            `role_forbidden`: a dashboard login without the `account_admin`
            role;

            `forbidden`: a widget token or a suspended tenant.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: role_forbidden
                  message: a dashboard login needs the account_admin role to change the tenant
                    calling window
        "422":
          description: Malformed window; the message names the reason. Nothing is stored.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
    delete:
      operationId: deleteTenantCallingWindow
      tags:
        - identity
      summary: Clear the tenant's calling hours
      description: |
        Back to no tenant window (idempotent). Same roles as `PUT`. The campaign
        and R18 windows are untouched.
      responses:
        "200":
          description: Cleared.
          content:
            application/json:
              schema:
                type: object
                required:
                  - calling_window
                properties:
                  calling_window:
                    type: "null"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/subaccounts:
    get:
      operationId: listSubaccounts
      tags:
        - identity
      summary: The caller's subaccounts
      description: >
        The sedes the caller's tenant opened (core#2851, ADR 370), oldest first.
        Another

        sede of the same empresa that is not a subaccount of the caller is never
        listed.

        `account_admin` login only.
      responses:
        "200":
          description: The subaccounts, closed ones included.
          content:
            application/json:
              schema:
                type: object
                required:
                  - subaccounts
                properties:
                  subaccounts:
                    type: array
                    items:
                      $ref: "#/components/schemas/Subaccount"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: "`role_forbidden`: not a dashboard login with the `account_admin`
            role."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
    post:
      operationId: createSubaccount
      tags:
        - identity
      summary: Open a subaccount
      description: >
        Opens a sede of the caller's own empresa (core#2851, ADR 370). It is
        billed to the

        parent's billing account and carries its own keys and limits. `max_cps`,

        `max_concurrent_channels` and `daily_spend_cap_usd` are the subaccount's
        OWN ceilings;

        omitted, the ones of its trust level apply. The effective limit is
        always the lower

        of the subaccount's and the parent's, read live (`effective_limits`), so
        a subaccount

        never exceeds its parent. A value above the parent's is `422
        exceeds_parent_limit`.

        The first `dd_` key (`environment` `live` or `test`) comes back ONCE in
        `token`.

        A subaccount cannot open subaccounts (`409 cannot_nest`).
        `account_admin` login only.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - name
              properties:
                name:
                  type: string
                site_label:
                  type: string
                max_cps:
                  type: integer
                  minimum: 1
                max_concurrent_channels:
                  type: integer
                  minimum: 1
                daily_spend_cap_usd:
                  type: string
                  description: Decimal USD.
                environment:
                  type: string
                  enum:
                    - live
                    - test
                  default: live
                key_label:
                  type: string
      responses:
        "201":
          description: The subaccount and its first key.
          content:
            application/json:
              schema:
                type: object
                required:
                  - subaccount
                  - api_key
                  - token
                properties:
                  subaccount:
                    $ref: "#/components/schemas/Subaccount"
                  api_key:
                    $ref: "#/components/schemas/ApiKey"
                  token:
                    type: string
                    description: The plaintext key, shown once.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: "`role_forbidden`: not a dashboard login with the `account_admin`
            role."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "409":
          description: "`cannot_nest`, `subaccount_limit` or `api_key_limit`."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "422":
          description: "`unprocessable` (name, environment) or `exceeds_parent_limit`."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/subaccounts/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    get:
      operationId: getSubaccount
      tags:
        - identity
      summary: One subaccount
      description: The subaccount with its own and its effective limits.
        `account_admin` login only.
      responses:
        "200":
          description: The subaccount.
          content:
            application/json:
              schema:
                type: object
                required:
                  - subaccount
                properties:
                  subaccount:
                    $ref: "#/components/schemas/Subaccount"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: "`role_forbidden`: not a dashboard login with the `account_admin`
            role."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "404":
          description: "`not_found`: no such subaccount of the caller (another sede of the
            empresa answers the same)."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
    patch:
      operationId: updateSubaccount
      tags:
        - identity
      summary: Rename a subaccount or change its own limits
      description: >
        A limit above the parent's effective one is `422 exceeds_parent_limit`.
        A closed

        subaccount is `409 subaccount_closed`. `account_admin` login only.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                site_label:
                  type: string
                max_cps:
                  type: integer
                  minimum: 1
                max_concurrent_channels:
                  type: integer
                  minimum: 1
                daily_spend_cap_usd:
                  type: string
                  description: Decimal USD.
      responses:
        "200":
          description: The updated subaccount.
          content:
            application/json:
              schema:
                type: object
                required:
                  - subaccount
                properties:
                  subaccount:
                    $ref: "#/components/schemas/Subaccount"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: "`role_forbidden`: not a dashboard login with the `account_admin`
            role."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "404":
          description: "`not_found`: no such subaccount of the caller (another sede of the
            empresa answers the same)."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "409":
          description: "`subaccount_closed`."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "422":
          description: "`exceeds_parent_limit`."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
  /v1/subaccounts/{id}/close:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    post:
      operationId: closeSubaccount
      tags:
        - identity
      summary: Close a subaccount
      description: >
        Sets the subaccount to `closed` and revokes every key it holds; its keys
        stop

        authenticating at once. Nothing is deleted. Closing a closed subaccount
        repeats the

        answer. `account_admin` login only.
      responses:
        "200":
          description: The closed subaccount.
          content:
            application/json:
              schema:
                type: object
                required:
                  - subaccount
                properties:
                  subaccount:
                    $ref: "#/components/schemas/Subaccount"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: "`role_forbidden`: not a dashboard login with the `account_admin`
            role."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "404":
          description: "`not_found`: no such subaccount of the caller (another sede of the
            empresa answers the same)."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
  /v1/subaccounts/{id}/keys:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    get:
      operationId: listSubaccountKeys
      tags:
        - identity
      summary: The keys of a subaccount
      description: Ids, labels, environment and revocation only — never the token.
        `account_admin` login only.
      responses:
        "200":
          description: The subaccount's keys.
          content:
            application/json:
              schema:
                type: object
                required:
                  - api_keys
                properties:
                  api_keys:
                    type: array
                    items:
                      $ref: "#/components/schemas/ApiKey"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: "`role_forbidden`: not a dashboard login with the `account_admin`
            role."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "404":
          description: "`not_found`: no such subaccount of the caller (another sede of the
            empresa answers the same)."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
    post:
      operationId: createSubaccountKey
      tags:
        - identity
      summary: Mint a key of a subaccount
      description: >
        A `dd_` key whose tenant is the subaccount: it reads and writes the
        subaccount's data

        and none of the parent's or of another subaccount's (RLS). `environment`
        is `live`

        (default) or `test`, fixed at creation. The token comes back ONCE.

        `account_admin` login only.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                label:
                  type: string
                environment:
                  type: string
                  enum:
                    - live
                    - test
                  default: live
      responses:
        "201":
          description: The key and its plaintext token.
          content:
            application/json:
              schema:
                type: object
                required:
                  - api_key
                  - token
                properties:
                  api_key:
                    $ref: "#/components/schemas/ApiKey"
                  token:
                    type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: "`role_forbidden`: not a dashboard login with the `account_admin`
            role."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "404":
          description: "`not_found`: no such subaccount of the caller (another sede of the
            empresa answers the same)."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "409":
          description: "`subaccount_closed` or `api_key_limit`."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/tenant/login-policy:
    get:
      operationId: getTenantLoginPolicy
      tags:
        - identity
      summary: The tenant's login policy
      description: >
        The standing login policy (ADR 227, core#1760), or `null` when none was

        ever written (the sign-in is then what it was before the policy
        existed).

        It is the allowlist of client addresses and the hours per role that the

        dashboard sign-in (`ddu_`) checks AFTER the credential is proven.

        `client_address_measured` says whether this cell measures the client

        address: when it is `false` an `ip_allow` cannot be armed here (the
        `PUT`

        answers `409 client_address_not_measured`) and only `windows` can.

        Readable by a `dd_` key and by an `account_admin` login; a `member`
        login

        is `403 role_forbidden`.
      responses:
        "200":
          description: The standing version, or `null`.
          content:
            application/json:
              schema:
                type: object
                required:
                  - login_policy
                  - client_address_measured
                properties:
                  login_policy:
                    oneOf:
                      - $ref: "#/components/schemas/TenantLoginPolicyVersion"
                      - type: "null"
                  client_address_measured:
                    type: boolean
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >
            `role_forbidden`: a dashboard login without the `account_admin`
            role;

            `forbidden`: a widget token or a suspended tenant.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
        "503":
          description: The policy could not be read (never answered as "no policy").
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
    put:
      operationId: putTenantLoginPolicy
      tags:
        - identity
      summary: Publish a new version of the tenant's login policy
      description: >
        Appends a version (nothing is edited in place; see `.../history`).

        **Only a `ddu_` session of an `account_admin` writes**: a `dd_` key is

        `403 role_forbidden`, because a stolen key that switches the control off

        would make it decor.


        `mode` defaults to `observe` (the sign-in is never denied; each attempt

        that would have been is recorded so the admin sees who would be left out

        BEFORE arming `enforce`). Refused, and nothing stored:


        * `422 unprocessable`: malformed body, a CIDR that does not parse, a
        `/0`,
          more than 64 entries, an unknown role, a window that does not parse or a
          missing `timezone`, or `enforce` with neither `ip_allow` nor `windows`.
        * `409 client_address_not_measured`: an `ip_allow` in `observe` or
        `enforce`
          in a cell whose client address is not measured.
        * `422 would_lock_out_caller`: with an `ip_allow`, the writer's own
        address
          cannot be attributed; with `enforce`, the writer's own address, role and
          the time of the request must pass the policy being written.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/TenantLoginPolicyInput"
            example:
              mode: observe
              ip_allow:
                - 203.0.113.0/24
              windows:
                member:
                  mon:
                    start: 08:00
                    end: 18:00
                  tue:
                    start: 08:00
                    end: 18:00
              timezone: America/Chicago
              reason: Office hours only
      responses:
        "200":
          description: The version that was stored.
          content:
            application/json:
              schema:
                type: object
                required:
                  - login_policy
                properties:
                  login_policy:
                    $ref: "#/components/schemas/TenantLoginPolicyVersion"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >
            `role_forbidden`: a `dd_` key or a dashboard login without the

            `account_admin` role; `forbidden`: a widget token or a suspended
            tenant.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: role_forbidden
                  message: writing the login policy takes a dashboard login with the account_admin
                    role
        "409":
          description: >
            `client_address_not_measured`: this cell does not measure the client

            address, so an `ip_allow` would compare against the proxy. Nothing
            is stored.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "422":
          description: >
            `unprocessable` (the message names the reason) or

            `would_lock_out_caller` (this policy would shut the writer out).
            Nothing

            is stored.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/tenant/login-policy/history:
    get:
      operationId: listTenantLoginPolicyHistory
      tags:
        - identity
      summary: Every published version of the tenant's login policy
      description: |
        Newest first. `set_by` is `user:<id>` for a version the tenant wrote and
        `operator:<id>` for a recovery by platform staff. Same readers as
        `GET /v1/tenant/login-policy`.
      parameters:
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            default: 50
            maximum: 200
          description: Page size (a value over the ceiling is capped, not refused).
        - name: cursor
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 2147483647
          description: The `next_cursor` of the previous page.
      responses:
        "200":
          description: A page of versions.
          content:
            application/json:
              schema:
                type: object
                required:
                  - versions
                  - next_cursor
                properties:
                  versions:
                    type: array
                    items:
                      $ref: "#/components/schemas/TenantLoginPolicyVersion"
                  next_cursor:
                    type:
                      - integer
                      - "null"
                    description: Pass as `cursor` for the next page; `null` on the last.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >
            `role_forbidden`: a dashboard login without the `account_admin`
            role;

            `forbidden`: a widget token or a suspended tenant.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
        "503":
          description: The history could not be read.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
  /v1/tenant/sms-business-hours:
    get:
      operationId: getTenantSmsBusinessHours
      tags:
        - sms
      summary: The tenant's business hours for SMS
      description: >
        The hours an inbound text is read against to decide whether it arrived

        OUTSIDE them (core#1777, ADR 228), or `null` when the tenant has none.

        With no hours the automatic reply never goes out. Readable by a `dd_`
        key

        and by any dashboard login.
      responses:
        "200":
          description: The stored hours, or `null`.
          content:
            application/json:
              schema:
                type: object
                required:
                  - sms_business_hours
                properties:
                  sms_business_hours:
                    oneOf:
                      - $ref: "#/components/schemas/TenantCallingWindow"
                      - type: "null"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
        "503":
          description: The stored hours could not be read (never answered as "no hours").
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
    put:
      operationId: putTenantSmsBusinessHours
      tags:
        - sms
      summary: Set the tenant's business hours for SMS
      description: >
        Replaces the hours; the body has the shape of the tenant calling window
        and

        is validated by the same code. **Tenant management only**: a `dd_` key
        or

        an `account_admin` login; a `member` login is `403 role_forbidden`. A

        malformed body is `422` with the reason and the previous hours stay. The

        write is audited. Saving the hours does not switch the auto-reply on.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/TenantCallingWindow"
            example:
              timezone: America/Chicago
              weekly:
                mon:
                  start: 09:00
                  end: 17:00
                tue:
                  start: 09:00
                  end: 17:00
      responses:
        "200":
          description: Stored (normalised).
          content:
            application/json:
              schema:
                type: object
                required:
                  - sms_business_hours
                properties:
                  sms_business_hours:
                    $ref: "#/components/schemas/TenantCallingWindow"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >
            `role_forbidden`: a dashboard login without the `account_admin`
            role;

            `forbidden`: a widget token or a suspended tenant.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "422":
          description: Malformed hours; the message names the reason. Nothing is stored.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
    delete:
      operationId: deleteTenantSmsBusinessHours
      tags:
        - sms
      summary: Clear the tenant's business hours for SMS
      description: >
        Back to no hours (idempotent), which turns the auto-reply off. Same
        roles

        as `PUT`.
      responses:
        "200":
          description: Cleared.
          content:
            application/json:
              schema:
                type: object
                required:
                  - sms_business_hours
                properties:
                  sms_business_hours:
                    type: "null"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
        "503":
          description: The hours could not be cleared.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
  /v1/tenant/sms-auto-reply:
    get:
      operationId: getTenantSmsAutoReply
      tags:
        - sms
      summary: The tenant's out-of-hours SMS auto-reply
      description: >
        The latest saved version of the reply sent to an inbound text received

        outside the tenant's business hours (core#1777, ADR 228), or `null` when

        none was ever saved. Reading it says nothing about whether it is active:

        that needs the operator flag `sms_auto_reply_enabled`, business hours,
        and

        every gate of the SMS send path. Readable by a `dd_` key and by any

        dashboard login.
      responses:
        "200":
          description: The latest version, or `null`.
          content:
            application/json:
              schema:
                type: object
                required:
                  - sms_auto_reply
                properties:
                  sms_auto_reply:
                    oneOf:
                      - $ref: "#/components/schemas/SmsAutoReply"
                      - type: "null"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
        "503":
          description: The stored reply could not be read (never answered as "none").
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
    put:
      operationId: putTenantSmsAutoReply
      tags:
        - sms
      summary: Save a new version of the SMS auto-reply
      description: >
        Appends a version (earlier ones are kept; the latest is in force).

        **Tenant management only**: a `dd_` key or an `account_admin` login; a

        `member` login is `403 role_forbidden`. The body is validated like the
        SMS

        send path validates it: it must carry an opt-out notice that the inbound

        keyword handling honours and must not contain a prohibited term; a

        rejection is `422` naming the field and nothing is stored. A tenant on

        templates-only cannot store free text (`422 sms_templates_only`).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SmsAutoReplyInput"
            example:
              body: We are closed right now. Reply STOP to opt out.
              language: en
              cooldown_seconds: 86400
      responses:
        "200":
          description: The version just saved.
          content:
            application/json:
              schema:
                type: object
                required:
                  - sms_auto_reply
                properties:
                  sms_auto_reply:
                    $ref: "#/components/schemas/SmsAutoReply"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >
            `role_forbidden`: a dashboard login without the `account_admin`
            role;

            `forbidden`: a widget token or a suspended tenant.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "422":
          description: |
            `unprocessable`: the message names the field and the reason;
            `sms_templates_only`: the tenant accepts templates only.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /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"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /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"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /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"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
    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 in one step with `POST /v1/api_keys/{id}/rotate`.


        `environment` (core#2837) is `live` (default; the historic `dd_` prefix)
        or

        `test` (token prefix `dd_test_`). A test key authenticates and reads
        through

        the same lanes and RLS as a live one, but reaches no outbound path: the
        routes

        that dial, send SMS, buy numbers or provision carriers answer

        `403 test_environment`, and nothing is metered or charged. The same
        answer covers

        the routes that mint, set or reset a live credential or identity
        (core#2892):

        `POST|DELETE /agents/{id}/credential`, `POST /api_keys/{id}/rotate`,
        `POST /users` and its `enable`,

        `mfa/reset-failures` and `mfa/remove`, `POST|PATCH
        /click-to-call/sites`,

        `POST /widget_tokens` (and `/sip_credential`) and `PUT|PATCH
        /sso/idp-config`.

        The environment is fixed at creation.


        Role rules (core#2359, core#2449): minting takes an `account_admin`
        login

        (`403 role_forbidden` for a `member` AND, once the tenant has an active

        `account_admin`, for a `dd_` key; per-tenant knob `api_key_admin_only`,
        default

        on; founder knob `api_key_manages_people`, default off). A tenant with
        no active

        `account_admin` keeps the `dd_` key minting. A key minted from a
        dashboard login

        records that person in `created_by_user_id`; every mint is written to
        the

        key audit trail.


        `allowed_cidrs` (core#2450) is the key's network allowlist; omitted or
        `[]`

        is no restriction. What a request from outside the list does is the
        tenant

        knob `api_key_network_enforced`: off (the default) serves and counts it,

        on answers `401`.


        `permissions` (core#2850) restricts the key to route GROUPS; absent or
        `null` is the

        key of always (every route). A key with a list gets `403
        permission_denied` on the

        routes of any other group (e.g. `/v1/billing/*` without `billing`); the
        group of a

        route is its first path segment, and a segment no group claims belongs
        to `general`.

        A key that itself holds a list cannot mint one with more (`403
        permission_escalation`).

        `expires_at` (ISO 8601, in the future; absent = never) is the instant
        from which the

        key stops authenticating (`401`, like a revoked one); other keys are
        unaffected.


        Limits (core#2503): a tenant holds at most `api_keys_max_live` LIVE keys

        (default 50; `409 api_key_limit` over it, revoking a key frees a slot),
        and

        minting is rate-limited per tenant (`429 rate_limited` with
        `Retry-After`).

        The request quota is per key, so a tenant's keys together are also
        bounded

        by `api_tenant_requests_per_minute` (default 5x one key's).
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                label:
                  type: string
                  description: Free-form label (default "").
                environment:
                  type: string
                  enum:
                    - live
                    - test
                  default: live
                  description: "`test` mints a key that never reaches calls, SMS or email
                    (`dd_test_` prefix). Immutable. Any other value is `422`."
                allowed_cidrs:
                  type: array
                  maxItems: 64
                  items:
                    type: string
                  description: CIDRs (or bare addresses) the key may be used from. A list with an
                    entry that does not parse is rejected whole (`400`); a value
                    that is not a list of strings is `422`.
                permissions:
                  oneOf:
                    - type: array
                      items:
                        $ref: "#/components/schemas/ApiKeyPermission"
                    - type: "null"
                  description: Route groups the key may enter. Absent or `null` = all. `[]` =
                    none. An unknown group is `422`.
                expires_at:
                  oneOf:
                    - type: string
                      format: date-time
                    - type: "null"
                  description: Instant from which the key stops authenticating. Must be in the
                    future (else `422`).
            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"
        "403":
          $ref: "#/components/responses/ApiKeyAdminForbidden"
        "409":
          $ref: "#/components/responses/ApiKeyLimit"
        "422":
          $ref: "#/components/responses/Unprocessable"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/api_keys/{id}:
    patch:
      operationId: updateApiKeyNetwork
      tags:
        - api-keys
      summary: Set the network allowlist of an API key
      description: >
        Replaces `allowed_cidrs` of a LIVE key (core#2450); `[]` lifts the

        restriction. Takes the same roles as minting (`403 role_forbidden` for a

        `member` while the knob `api_key_admin_only` is on). The change is
        written

        to the key audit trail as `network_changed` and binds the next request:
        the

        key is forgotten by the authentication cache. A revoked or unknown key
        is

        `404`. A key that holds a permission list may change only itself and
        keys whose

        permissions it covers: otherwise `403 permission_escalation`
        (core#2914).


        A `test` API key (core#2965) may act only on `test` keys: against a
        `live` key it is refused with

        `403 test_environment` and the key is left untouched.
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - allowed_cidrs
              properties:
                allowed_cidrs:
                  type: array
                  maxItems: 64
                  items:
                    type: string
            example:
              allowed_cidrs:
                - 203.0.113.0/24
                - 2001:db8::/32
      responses:
        "200":
          description: The key with its new list.
          content:
            application/json:
              schema:
                type: object
                required:
                  - api_key
                properties:
                  api_key:
                    $ref: "#/components/schemas/ApiKey"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ApiKeyAdminForbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/Unprocessable"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
    delete:
      operationId: revokeApiKey
      tags:
        - api-keys
      summary: Revoke an API key
      description: >
        Sets `revoked_at`; keys are never deleted. Idempotent revocation
        semantics.


        Role rules (core#2359, core#2449): revoking takes an `account_admin`
        login

        (`403 role_forbidden` for a `member` AND, once the tenant has an active

        `account_admin`, for a `dd_` key; per-tenant knob `api_key_admin_only`,
        default

        on; founder knob `api_key_manages_people`, default off). A tenant with
        no active

        `account_admin` keeps the `dd_` key revoking. Every revocation is
        written to the key

        audit trail. A key that holds a permission list may revoke only itself
        and keys whose

        permissions it covers: otherwise `403 permission_escalation`
        (core#2914).


        A `test` API key (core#2965) may act only on `test` keys: against a
        `live` key it is refused with

        `403 test_environment` and the key is left untouched.
      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"
        "403":
          $ref: "#/components/responses/ApiKeyAdminForbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/api_keys/{id}/rotate:
    post:
      operationId: rotateApiKey
      tags:
        - api-keys
      summary: Rotate an API key in one step, with an overlap
      description: >
        Mints a new key with the same label, environment, network list and
        permissions

        (core#2850) and leaves the old one authenticating for `overlap_seconds`
        (default

        3600, at most 86400; `0` cuts it at once); after that only the new key
        does. The

        old key's `expires_at` becomes the end of the overlap (never later than
        one it

        already had) and it is returned as `rotated`. The new token is shown
        ONCE. The key

        audit trail gets `issued` (new) and `rotated` (old). Same roles, mint
        rate limit and

        live-key limit as minting; during the overlap both keys count toward the
        limit.

        A key that holds a permission list rotates only keys whose permissions
        it covers

        (`403 permission_escalation`). A revoked, expired or unknown key is
        `404`.
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                overlap_seconds:
                  type: integer
                  minimum: 0
                  maximum: 86400
                  default: 3600
                expires_at:
                  oneOf:
                    - type: string
                      format: date-time
                    - type: "null"
                  description: Optional expiry for the NEW key (the old key's expiry is not
                    inherited).
            example:
              overlap_seconds: 1800
      responses:
        "201":
          description: The new key (with its token, once) and the old one with its overlap
            end.
          content:
            application/json:
              schema:
                type: object
                required:
                  - api_key
                  - token
                  - rotated
                properties:
                  api_key:
                    $ref: "#/components/schemas/ApiKey"
                  token:
                    type: string
                    description: Plaintext bearer token, shown exactly once.
                  rotated:
                    $ref: "#/components/schemas/ApiKey"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ApiKeyAdminForbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/ApiKeyLimit"
        "422":
          $ref: "#/components/responses/Unprocessable"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/api_keys/{id}/requests:
    get:
      operationId: listApiKeyRequests
      tags:
        - api-keys
      summary: Requests made with one API key
      description: >
        The request trail of ONE `dd_` key, newest first, keyset-paginated:
        method, route

        TEMPLATE (`/v1/calls/:call_id`, never the URL with ids), HTTP status,
        duration and the

        `request_id` the caller also saw in `x-request-id`. A request shows up
        within seconds

        (the writer is asynchronous, off the request path).


        **Never stored:** request or response bodies, headers (`Authorization`
        included), the

        query string, the IP or the key hash. Rows are kept
        `API_REQUEST_LOG_RETENTION_DAYS`

        days (default 30, minimum 1). A `429` from the key quota is a row like
        any other; it

        carries the route template the router had matched (`/v1` when none yet).


        Filters: `since` (inclusive) and `until` (exclusive), RFC-3339;
        `status`, one exact

        HTTP status. A malformed filter is **400**, never a silently empty page.
        Tenant scope

        (`dd_` key or dashboard login); a key of another tenant is **404**.
      parameters:
        - $ref: "#/components/parameters/PathId"
        - name: since
          in: query
          required: false
          schema:
            type: string
            format: date-time
        - name: until
          in: query
          required: false
          schema:
            type: string
            format: date-time
        - name: status
          in: query
          required: false
          schema:
            type: integer
            minimum: 100
            maximum: 599
        - 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 requests, newest first.
          content:
            application/json:
              schema:
                type: object
                required:
                  - requests
                  - next_cursor
                properties:
                  requests:
                    type: array
                    items:
                      $ref: "#/components/schemas/ApiRequest"
                  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"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/api_keys/{id}/requests/summary:
    get:
      operationId: summarizeApiKeyRequests
      tags:
        - api-keys
      summary: Requests per minute of one API key, with its 429s
      description: >
        Counts per minute of the requests of ONE key: `requests`, `rate_limited`
        (status 429,

        the key or tenant quota) and `server_errors` (status >= 500). Only
        minutes with traffic

        appear, newest first. Window `[since, until)`: by default the last hour,
        at most 1440

        minutes (**400** beyond that). Same scope, retention and 404 rule as the
        list.
      parameters:
        - $ref: "#/components/parameters/PathId"
        - name: since
          in: query
          required: false
          schema:
            type: string
            format: date-time
        - name: until
          in: query
          required: false
          schema:
            type: string
            format: date-time
      responses:
        "200":
          description: The per-minute counts.
          content:
            application/json:
              schema:
                type: object
                required:
                  - minutes
                properties:
                  minutes:
                    type: array
                    items:
                      $ref: "#/components/schemas/ApiRequestMinute"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /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.

        Socket cap (core#1285): a handshake refused with `429` reaches a
        browser as a bare close `1006`, indistinguishable from a network drop.
        So this endpoint reads the SAME caps as the upgrade door — the
        widget token's first on the `ddw_` lane, then the tenant's — and, when
        one is full, answers `429 too_many_connections` with a machine-readable
        `scope` and mints nothing. It is an early warning, not the control: a
        ticket minted with room to spare is still refused at the upgrade if
        the cap fills before it is redeemed.
      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"
        "429":
          description: "The live-floor socket cap is full (`too_many_connections`); no
            ticket is minted. `scope` says which cap: `widget_token` (this
            `ddw_` already holds its sockets) or `tenant` (the tenant's cap).
            Retry once a socket closes, not in a loop."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TooManyConnectionsError"
              examples:
                tenant:
                  value:
                    error:
                      code: too_many_connections
                      message: live-floor connection limit reached for this tenant
                      scope: tenant
                widget_token:
                  value:
                    error:
                      code: too_many_connections
                      message: live-floor connection limit reached for this widget token
                      scope: widget_token
        "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 and open invitations
      description: |
        Emails, names, status and login timestamps — password hashes are never
        exposed. `invitations` lists the OPEN invitations (neither accepted
        nor superseded) sent by `POST /v1/users`, newest first: `pending`
        until they expire, `expired` after. An invitation to an address that
        is already a user of another tenant is listed, and expires, exactly
        like one nobody opened — this list says nothing about who else the
        address belongs to. An accepted invitation leaves this list and its
        person appears in `users`.
      responses:
        "200":
          description: The tenant's dashboard users and its open invitations.
          content:
            application/json:
              schema:
                type: object
                required:
                  - users
                  - invitations
                  - truncated
                properties:
                  users:
                    type: array
                    items:
                      $ref: "#/components/schemas/User"
                  invitations:
                    type: array
                    items:
                      $ref: "#/components/schemas/UserInvitation"
                  truncated:
                    type: boolean
                    description: >
                      `true` when there are more rows than the 500 users or 500
                      open invitations (it is `true` if either list was cut):
                      the response is a

                      prefix in the documented order, not the whole set.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
    post:
      operationId: createUser
      tags:
        - users
      summary: Invite a person to the dashboard
      description: >
        **Invites** — it does not create the user (ADR #182, a breaking change

        of this operation: it used to take a `password` and answer `201` with

        the created user). It records an invitation and e-mails the address a

        single-use link (valid 72 h) to `/accept-invite`; the person is created

        when they redeem it with the password THEY choose, through

        `POST /v1/auth/password-reset/confirm`. An address is therefore only

        taken by someone who proved they read its mail.


        The answer is `202` with the SAME body — byte for byte — whether the

        address is free or already a user of ANOTHER tenant. In the second

        case the invitation is recorded and listed all the same, and the

        address receives a notice that it already has an account (with no

        link): the inviting tenant learns nothing about other tenants. Only an

        address that is already a user of THIS tenant, active or disabled, is

        a `409` — that row is in this tenant's own `GET /v1/users` anyway.


        Role rules (core#2334, core#2449): once the tenant has an active
        `account_admin`, this takes an

        `account_admin` login (`403 role_forbidden` for a `member` AND for a
        `dd_` key; per-tenant knob

        `user_management_admin_only`, default on; founder knob
        `api_key_manages_people`, default off, gives the

        `dd_` key its old reach back). A tenant with no active `account_admin`
        keeps the old posture, `dd_` key

        included (it invites the first person). The invitation records who made
        it (`invited_by`).


        Inviting the same address again supersedes the previous link (that is

        how an invitation is resent). Two brakes bound the mail this route can

        send: per tenant (a visible `429`) and per address across every tenant

        (invisible: over it the invitation is still recorded and the answer is

        the same `202`, but no mail leaves — a `429` there would tell one

        tenant that another just invited that address).


        Bootstrap runs with the `dd_` machine key (it invites the FIRST user);

        after that any `ddu_` session can manage users (all users are console

        admins day-1). `tenant_id` is never read from the body. A `password`

        in the body is a `400`: the invited person chooses it.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - email
              properties:
                email:
                  type: string
                  format: email
                  maxLength: 254
                name:
                  type: string
                  maxLength: 200
                locale:
                  type: string
                  enum:
                    - es
                    - en
                  description: >
                    Language the inviting admin has the console in; the
                    invitation (and the

                    "this address already has an account" notice) goes out in
                    it. Absent or

                    anything else: the cell's language (`DEFAULT_LOCALE`). Never
                    an error.
            example:
              email: ops@acme-collections.test
              name: Ops Admin
              locale: es
      responses:
        "202":
          description: Invitation recorded. Identical for every recorded outcome (see the
            description).
          content:
            application/json:
              schema:
                type: object
                required:
                  - status
                  - message
                properties:
                  status:
                    type: string
                    enum:
                      - invited
                  message:
                    type: string
                additionalProperties: false
              example:
                status: invited
                message: invitation recorded
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/UserManagementForbidden"
        "409":
          description: >
            The address is already a user of THIS tenant (active or disabled),
            or

            a concurrent invitation to it from this same tenant won the race.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: conflict
                  message: that address is already a user of this tenant
        "429":
          description: >
            This tenant's invitation brake refused the request (fixed window;

            retryable later). `code` `rate_limited`. Keyed per tenant only — it

            says nothing about the address. `Retry-After` is the time left in
            the

            fixed window.
          headers:
            Retry-After:
              $ref: "#/components/headers/RetryAfter"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: rate_limited
                  message: user invitation rate limit exceeded; retry later
  /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`.


        Refused with `409` when it would lock the account out (core#2328):

        `cannot_disable_self` when the caller is the target (a dashboard

        login; a `dd_` key is no person), and `last_account_admin` when the

        target is the only active `account_admin` of the tenant. An

        already-disabled user stays idempotent.


        Role rules (core#2334): once the tenant has an active `account_admin`,
        this takes a `dd_` key or an

        `account_admin` login (`403 role_forbidden` for a `member` AND, since
        core#2449, for a `dd_` key; per-tenant

        knob `user_management_admin_only`, default on; founder knob
        `api_key_manages_people`, default off). A

        tenant with no active `account_admin` keeps the old posture, `dd_` key
        included.

        A `member` can never disable an `account_admin` (`403
        admin_target_forbidden`).


        Keys the person minted (core#2359): the response lists every `dd_` key
        their

        session minted in `issued_api_keys` (live ones have `revoked_at: null`),
        so

        the administrator can rotate what a leaver left behind. Per-tenant knob

        `user_disable_revokes_api_keys` (default off): when on, disabling the
        person

        first revokes their live keys.


        Seat and SIP credential (core#2405): per-tenant knob
        `user_disable_retires_seat`

        (default on). The agent linked to the person leaves the agent FSM (armed
        while

        a call is in flight), its `agentcred` entry and usrloc binding are
        evicted and

        its widget login is switched off; `seat_retirement` reports each step

        (`null` with the knob off). `POST /v1/auth/agent-login` refuses an agent
        whose

        linked person is not active.
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: The disabled user and the API keys that person minted.
          content:
            application/json:
              schema:
                type: object
                required:
                  - user
                  - issued_api_keys
                properties:
                  user:
                    $ref: "#/components/schemas/User"
                  issued_api_keys:
                    type: array
                    items:
                      $ref: "#/components/schemas/ApiKey"
                  seat_retirement:
                    type:
                      - object
                      - "null"
                    description: What happened to the linked agent's seat; `null` when
                      `user_disable_retires_seat` is off.
                    properties:
                      seat:
                        type: string
                        enum:
                          - retired
                          - armed
                          - none
                          - failed
                      sip_teardown:
                        type: string
                        enum:
                          - ok
                          - skipped
                          - failed
                      widget_login:
                        type: string
                        enum:
                          - disabled
                          - none
                          - failed
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/UserManagementForbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: >
            Refused: `cannot_disable_self` (the caller is the target) or

            `last_account_admin` (the target is the only active
            `account_admin`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: last_account_admin
                  message: the last active account_admin cannot be disabled; promote another one
                    first
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /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.


        Role rules (core#2334): once the tenant has an active `account_admin`,
        this takes a `dd_` key or an

        `account_admin` login (`403 role_forbidden` for a `member` AND, since
        core#2449, for a `dd_` key; per-tenant

        knob `user_management_admin_only`, default on; founder knob
        `api_key_manages_people`, default off). A

        tenant with no active `account_admin` keeps the old posture, `dd_` key
        included.

        A `member` can never re-enable an `account_admin` (`403
        admin_target_forbidden`).
      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/UserManagementForbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/users/{id}/mfa/reset-failures:
    post:
      operationId: resetUserMfaFailures
      tags:
        - users
      summary: Lift a person's second-factor brake
      description: >
        Clears the cumulative brake of the second factor (core#1572) for one
        person

        of the tenant: writes a `failures_reset` event authored by the calling
        admin,

        and the refused codes before it stop counting. Nothing is deleted from
        the

        audit trail. **`account_admin` dashboard login only** (see `403`).
      security:
        - userSessionToken: []
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "204":
          description: The failure count was reset.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/users/{id}/mfa/remove:
    post:
      operationId: removeUserMfa
      tags:
        - users
      summary: Remove a person's second factor (lost authenticator and recovery codes)
      description: >
        The recovery path for a person who lost BOTH the authenticator and the
        recovery

        codes (core#1580): an `account_admin` of the SAME tenant removes that
        person's

        active second factor. In ONE transaction — if any step fails the factor
        is left

        intact: the factor and its unused recovery codes stop being valid, EVERY
        live

        `ddu_` session of the person is revoked, and a `factor_removed_by_admin`
        event

        (author = the calling admin, target = the person, the written `reason`)
        is

        appended to the second-factor audit trail. Nothing is deleted.


        It opens no door without a second factor: when the tenant requires it

        (`mfa_required_enforced`) the person's next login lands in the forced

        enrollment; otherwise the account simply has no second factor until they
        enrol

        again.


        **`account_admin` dashboard login only** (see `403`): never the person
        themself

        (they have no factor to prove) and never a `dd_` key. An admin cannot
        use it on

        THEMSELVES (`409`): their own path is `POST /v1/me/mfa/disable`, which

        re-authenticates. A disabled person is `404` — re-enable them first.
      security:
        - userSessionToken: []
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - reason
              properties:
                reason:
                  type: string
                  minLength: 1
                  maxLength: 500
                  description: >
                    Why the factor is being removed — free text, kept in the
                    audit trail.

                    Leading and trailing whitespace is trimmed before counting.
            example:
              reason: Lost phone and printed codes; identity confirmed on a video call.
      responses:
        "204":
          description: The factor was removed, the person's sessions revoked and the event
            written.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: >
            Nothing to remove or not this route: the person has no ACTIVE second
            factor, or

            the caller is that same person (use `POST /v1/me/mfa/disable`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /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"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
    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.


        WHO the token is minted for depends on the principal (ADR #133, ADR

        #134). A `dd_` machine key names any agent of its tenant and `agent_id`

        is required for it (`400` when missing). A `ddu_` login session whose

        person is linked to an agent seat mints for THAT seat: `agent_id` may be

        omitted (filled with the seat) or repeat it; naming another agent is

        `403 forbidden` and no token is minted — a person cannot coin another

        agent's credential. A `ddu_` without a linked seat may mint for another

        agent only with `users.role = account_admin` — a `member` is `403

        role_forbidden` — once the tenant's catalog key

        `ddu_author_role_enforced` is on (born off: until then the seatless

        session keeps the `dd_` contract, `agent_id` required).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                agent_id:
                  type: string
                  format: uuid
                  description: The agent this token acts as. REQUIRED for a `dd_` key; optional
                    (and, if present, must be the caller's own seat) for a
                    `ddu_` session linked to a seat.
                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"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /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_tokens/sip_credential:
    post:
      operationId: issueWidgetSipCredential
      tags:
        - widgets
      summary: Mint the caller's own ephemeral SIP credential
      description: >
        Provisions the EPHEMERAL SIP credential (`agentcred_w`) of the

        AUTHENTICATED `ddw_` session and returns it once, in memory — the
        frame's

        bootstrap for registering over WSS (MT-CTI-07 fase 2).


        Authenticated by the `ddw_` itself and operating ONLY on the caller's
        own

        session: the token id comes from the bearer, never from a path or body

        value, so one session can neither mint nor read another's. The
        credential

        is per TOKEN (`w-<token_id>@t-<tenant>.<base>`), never per agent

        extension, and lives in its own htable and its own usrloc AOR — revoking

        it cannot reach the agent's softphone credential, nor the other way

        round.


        FAIL-CLOSED: if the credential cannot be issued, NOTHING is returned.

        There is no fallback to `agentcred`, the agent's 24 h softphone

        credential; a frame with no audio is the intended outcome.


        Idempotent per session: a re-issue OVERWRITES the key, so the PREVIOUS

        password of that same session stops working. Rate-limited per token.


        The `password` is returned EXACTLY ONCE and is stored nowhere on the

        server side — not in Postgres, not in a log line, not in an audit row.

        The audit trail records THAT a credential was issued, never WHAT.
      security:
        - widgetSessionToken: []
      responses:
        "200":
          description: >
            The bootstrap, or `sip: null` when the widget SIP lane is not armed
            in

            this environment (`WIDGET_SIP_ENABLED=false`, or no SBC configured).

            `null` is a DEPLOYMENT state, not a failure of the caller: no

            credential is invented and the frame simply has no audio. Branch on

            it rather than retrying.
          content:
            application/json:
              schema:
                type: object
                required:
                  - sip
                properties:
                  sip:
                    oneOf:
                      - $ref: "#/components/schemas/WidgetSipCredential"
                      - type: "null"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/WidgetRateLimited"
        "503":
          $ref: "#/components/responses/WidgetSipUnavailable"
    delete:
      operationId: releaseWidgetSipCredential
      tags:
        - widgets
      summary: Hand back the caller's own ephemeral SIP credential
      description: |
        Deletes the `agentcred_w` credential of the AUTHENTICATED `ddw_` session
        and its usrloc binding, WITHOUT ending the session: the token stays live
        and may mint again. What the frame calls on unload, on logout, or when
        the agent moves to a deskphone.

        Its reason to exist is the residue: without it the only teardown for a
        session that stops using audio is the htable autoexpire or revoking the
        whole token — up to 12 h of a live credential nobody is going to use.

        Idempotent, and never an error to call for a session that never lit SIP
        up. Scoped to the caller's own session (the token id comes from the
        bearer), so it can never reach a sibling session's credential, nor the
        agent's `agentcred`.
      security:
        - widgetSessionToken: []
      responses:
        "200":
          description: >
            The teardown outcome. `failed` is reported in a 200 ON PURPOSE (ADR

            #56 D3: an SBC failure is said, not eaten): the frame's session does

            not depend on the teardown having succeeded, and what is left over
            is

            settled anyway by the htable autoexpire or the session-cap sweeper.

            Do not retry in a loop on it.
          content:
            application/json:
              schema:
                type: object
                required:
                  - sip_teardown
                properties:
                  sip_teardown:
                    $ref: "#/components/schemas/WidgetSipTeardown"
        "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"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
    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.


        Role rules (core#2439): a `dd_` key or an `account_admin` login; a
        `member` login is `403 role_forbidden` (per-tenant knob
        `telephony_config_admin_only`, default on). The `GET` of this resource
        is not affected.
      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"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /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.


        Role rules (core#2439): a `dd_` key or an `account_admin` login; a
        `member` login is `403 role_forbidden` (per-tenant knob
        `telephony_config_admin_only`, default on). The `GET` of this resource
        is not affected.
      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"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/widget_origins/{id}/enable:
    post:
      operationId: enableWidgetOrigin
      tags:
        - widgets
      summary: Re-enable an allowlisted origin
      description: >
        Role rules (core#2439): a `dd_` key or an `account_admin` login; a
        `member` login is `403 role_forbidden` (per-tenant knob
        `telephony_config_admin_only`, default on). The `GET` of this resource
        is not affected.
      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"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/click-to-call/sites:
    get:
      operationId: listClickToCallSites
      tags:
        - click-to-call
      summary: List the tenant's click-to-call sites
      description: >
        Every site of the tenant, newest first, revoked ones included
        (`revoked_at`

        set). Each carries its CURRENT consent text, version and sha256. Never

        carries the key (only its sha256 is stored). Reads are not gated by

        `click_to_call_enabled`.
      responses:
        "200":
          description: The tenant's sites.
          content:
            application/json:
              schema:
                type: object
                required:
                  - sites
                properties:
                  sites:
                    type: array
                    items:
                      $ref: "#/components/schemas/ClickToCallSite"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
    post:
      operationId: mintClickToCallSite
      tags:
        - click-to-call
      summary: Mint a click-to-call site key
      description: >
        Creates a site bound to one campaign and returns its publishable `ddc_`

        key. The plaintext is shown exactly once; only its sha256 is stored.

        Requires `click_to_call_enabled` for the tenant (`403` otherwise).

        A `consent_text` given here becomes version 1; without it the site has
        no

        consent text and refuses every request (`409 consent_version_mismatch`)

        until one is configured with `PATCH /v1/click-to-call/sites/{id}`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ClickToCallSiteInput"
            example:
              campaign_id: 7f1c0b0e-5d3a-4c55-9a3e-0b6f3b1f2a10
              label: Landing de ventas
      responses:
        "201":
          description: The site and its key (shown once).
          content:
            application/json:
              schema:
                type: object
                required:
                  - site
                  - token
                properties:
                  site:
                    $ref: "#/components/schemas/ClickToCallSite"
                  token:
                    type: string
                    description: Plaintext ddc_ site key, shown exactly once.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/click-to-call/sites/{id}:
    get:
      operationId: getClickToCallSite
      tags:
        - click-to-call
      summary: Read one click-to-call site
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: The site with its current consent text.
          content:
            application/json:
              schema:
                type: object
                required:
                  - site
                properties:
                  site:
                    $ref: "#/components/schemas/ClickToCallSite"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
    patch:
      operationId: updateClickToCallSite
      tags:
        - click-to-call
      summary: Configure a click-to-call site
      description: >
        Partial update of label, campaign, allowed modes, caps and consent text.

        A `consent_text` that differs from the current one inserts version N+1

        (the previous versions are append-only evidence and are never modified
        or

        deleted); the same text again changes nothing. The identity, the key and

        `revoked_at` cannot be changed. A revoked site is read-only (`409`).

        Not gated by `click_to_call_enabled`.
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ClickToCallSiteUpdate"
            example:
              number_daily_cap: 3
              consent_text: Acepto que me llamen al número indicado.
      responses:
        "200":
          description: The updated site.
          content:
            application/json:
              schema:
                type: object
                required:
                  - site
                properties:
                  site:
                    $ref: "#/components/schemas/ClickToCallSite"
        "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"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
    delete:
      operationId: revokeClickToCallSite
      tags:
        - click-to-call
      summary: Revoke a click-to-call site key
      description: Revokes the key (idempotent, a second call returns the same revoked
        site). Available even with `click_to_call_enabled` off — a key can
        always be killed. Requests already received are evidence and are never
        deleted.
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: The revoked site.
          content:
            application/json:
              schema:
                type: object
                required:
                  - site
                properties:
                  site:
                    $ref: "#/components/schemas/ClickToCallSite"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/public/call-request-config:
    get:
      operationId: getCallRequestConfig
      tags:
        - click-to-call
      summary: Read the call-request form's configuration
      description: >
        What the visitor's form needs to draw itself. Nothing about the tenant's

        internals (no campaign, no caps). It carries the CURRENT consent text
        with

        its `consent_version` and `consent_sha256` (sha256 of the text's bytes,

        computed by the server): the form must show exactly that text and echo
        the

        version and hash back in `POST /v1/public/call-requests`.
      security:
        - publicSiteKey: []
      responses:
        "200":
          description: The site's public configuration.
          content:
            application/json:
              schema:
                type: object
                required:
                  - config
                properties:
                  config:
                    $ref: "#/components/schemas/ClickToCallPublicConfig"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: The lane is off (cell ceiling closed or the tenant's
            `click_to_call_enabled` off). Byte-identical to any unknown route.
  /v1/public/call-requests:
    post:
      operationId: createCallRequest
      tags:
        - click-to-call
      summary: Leave a call request
      description: >
        INSERT-ONLY. Records the visitor's declaration (never a consent) and

        answers `202 {request_id}` — the answer says nothing about whether the

        number belongs to a customer of the tenant.


        The `consent_text_version` and `consent_text_sha256` must be the site's

        CURRENT ones (the form got them from `GET
        /v1/public/call-request-config`);

        otherwise the answer is `409 consent_version_mismatch` and nothing is

        written (fail-closed). A site with no consent text configured always

        answers `409`. The accepted version is stored with the request.


        Volume is bounded by DURABLE caps counted in the insert's transaction

        (the site's `tenant_daily_cap`, `number_daily_cap`, `site_hourly_cap`,

        rolling windows) and by a per-site rate bucket. Every one answers the

        same `429`, writes nothing and carries no `Retry-After`. If the caps

        cannot be read the answer is `503` and nothing is written. An optional

        `captcha_token` is checked by the configured provider (none by default).
      security:
        - publicSiteKey: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CallRequestInput"
      responses:
        "202":
          description: Received.
          content:
            application/json:
              schema:
                type: object
                required:
                  - request_id
                properties:
                  request_id:
                    type: string
                    format: uuid
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: The lane is off (cell ceiling closed or the tenant's
            `click_to_call_enabled` off). Byte-identical to any unknown route;
            nothing is written.
        "409":
          description: "`consent_version_mismatch` — the version or hash does not match
            the site's current consent text. Nothing is written; reload the form
            configuration."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "422":
          description: "`consent_not_checked` — the consent checkbox was sent unchecked
            (`consent_checked: false`); a request that says the visitor did not
            declare is not a call request. Also `unprocessable` for a mode the
            site does not allow. Nothing is written."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "429":
          description: A volume cap or the per-site bucket was reached. Same body for
            every cap; no `Retry-After`; nothing is written.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "503":
          description: The caps (or the captcha provider) could not be read, so the
            request is not admitted. Nothing is written.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
  /v1/campaigns:
    get:
      operationId: listCampaigns
      tags:
        - campaigns
      summary: List campaigns
      parameters:
        - name: environment
          in: query
          required: false
          description: Only campaigns created with a key of this environment (core#2876).
            Absent = all.
          schema:
            type: string
            enum:
              - test
              - live
      responses:
        "200":
          description: All campaigns of the tenant.
          content:
            application/json:
              schema:
                type: object
                required:
                  - campaigns
                  - truncated
                properties:
                  campaigns:
                    type: array
                    items:
                      $ref: "#/components/schemas/Campaign"
                  truncated:
                    type: boolean
                    description: >
                      `true` when there are more rows than the 2 000 campaigns
                      the listing returns: the response is a

                      prefix in the documented order, not the whole set.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
    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; `email_blast` the
        `:email`-channel

        type, which sends an approved email template: `email_template_id` is
        REQUIRED and

        `email_template_variables` carries its static values). `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`.


        A campaign that runs WITHOUT an agent (`tts_blast`, `tts_ivr`,
        `ivr_cascade`,

        `survey`, `ai_voice`, `sms_blast`, `email_blast`) is refused with `403`

        `verification_required` while the account is a 1-seat account that has
        not been

        verified (`macro-tasks#870`, point 7). Campaigns with an agent are not
        affected.
      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
                  calling_window_weekly: null
                  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"
        "403":
          $ref: "#/components/responses/VerificationRequired"
        "409":
          $ref: "#/components/responses/Conflict"
        "422":
          $ref: "#/components/responses/CampaignWeeklyWindowRefused"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /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"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
    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. Changing

        `campaign_type` to a type that runs without an agent is refused with
        `403`

        `verification_required` on an unverified 1-seat account (same rule as
        create).
      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"
        "403":
          $ref: "#/components/responses/VerificationRequired"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "422":
          $ref: "#/components/responses/CampaignWeeklyWindowRefused"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /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, `email_blast` → e-mail
        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`.

        A campaign in `exhausted` (its feed ran dry, R8) starts again here, back
        to `running`.

        A campaign that runs without an agent is refused with `403`
        `verification_required` while

        the account is an unverified 1-seat account (`macro-tasks#870`, point
        7).
      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"
        "403":
          $ref: "#/components/responses/VerificationRequired"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /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"
        "403":
          $ref: "#/components/responses/VerificationRequired"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /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"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /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"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /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"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/campaigns/{id}/experiments:
    get:
      operationId: listCampaignExperiments
      tags:
        - campaigns
      summary: A/B experiments of a campaign
      description: Every experiment of the campaign (any state), newest first, each
        with its variants in `position` order.
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: The campaign's experiments.
          content:
            application/json:
              schema:
                type: object
                required:
                  - experiments
                properties:
                  experiments:
                    type: array
                    items:
                      $ref: "#/components/schemas/Experiment"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
    post:
      operationId: createCampaignExperiment
      tags:
        - campaigns
      summary: Create an A/B experiment (draft)
      description: >
        Creates a `draft` experiment of 2 to 5 variants with exactly one
        control.

        A variant may override only the CLOSED list `script`, `window`,

        `min_gap_seconds` and `email_template_id`: any other key (in particular

        `optout_digit`, `abandonment_threshold` and `max_dial_ratio`) is
        refused.

        A `script` goes through the campaign's own validators (an `sms_body`

        without «STOP» is refused) and a `window` must be a subset of the

        campaign's. `window` and `min_gap_seconds` only apply to a voice
        campaign,

        and an SMS or email campaign cannot choose `promise_rate` (a promise
        points

        at a call, not at a message): both are refused. Every refused field
        comes

        back in ONE `422`, each with its path in `error.details`.
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ExperimentWrite"
      responses:
        "201":
          description: The draft experiment.
          content:
            application/json:
              schema:
                type: object
                required:
                  - experiment
                properties:
                  experiment:
                    $ref: "#/components/schemas/Experiment"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/ExperimentUnprocessable"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/campaigns/{id}/experiments/{experiment_id}:
    get:
      operationId: getCampaignExperiment
      tags:
        - campaigns
      summary: Fetch an A/B experiment
      parameters:
        - $ref: "#/components/parameters/PathId"
        - $ref: "#/components/parameters/ExperimentId"
      responses:
        "200":
          description: The experiment with its variants.
          content:
            application/json:
              schema:
                type: object
                required:
                  - experiment
                properties:
                  experiment:
                    $ref: "#/components/schemas/Experiment"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
    patch:
      operationId: updateCampaignExperiment
      tags:
        - campaigns
      summary: Edit a draft A/B experiment
      description: >
        `name` and/or `variants` (which REPLACES the whole set);
        `primary_metric`

        may only be repeated, never changed. Only a `draft` can be edited: once

        it runs (or ended) its variants are frozen and this is `409`.
      parameters:
        - $ref: "#/components/parameters/PathId"
        - $ref: "#/components/parameters/ExperimentId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ExperimentWrite"
      responses:
        "200":
          description: The edited experiment.
          content:
            application/json:
              schema:
                type: object
                required:
                  - experiment
                properties:
                  experiment:
                    $ref: "#/components/schemas/Experiment"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "422":
          $ref: "#/components/responses/ExperimentUnprocessable"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
    delete:
      operationId: deleteCampaignExperiment
      tags:
        - campaigns
      summary: Delete a draft A/B experiment
      description: Only a `draft` can be deleted; a running or ended experiment is `409`.
      parameters:
        - $ref: "#/components/parameters/PathId"
        - $ref: "#/components/parameters/ExperimentId"
      responses:
        "204":
          description: Deleted.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/campaigns/{id}/experiments/{experiment_id}/results:
    get:
      operationId: getCampaignExperimentResults
      tags:
        - campaigns
      summary: Results and verdict of an A/B experiment
      description: >
        Recomputed from the ledger on every read (no counters): the original

        attempts stamped with the experiment, their corrections and the promises
        that

        point at them. For an SMS or email campaign the ledger is its message
        ledger:

        the original outbound messages and their status transitions, where a
        message

        the provider never took is `not_placed`, a contact is a `delivered`
        message and

        `promises` and `abandoned` are 0 (their rates `null`). Per variant:
        assigned debts, attempts, placed attempts (the

        `not_placed` ones are outside every denominator), contacts, promises,
        abandons

        and the rates. `verdict.status` is `insufficient_data` (some arm is
        under 100

        placed attempts or 10 successes), `no_significant_difference` or
        `winner`

        (two-proportion z test, alpha 0.05, Bonferroni with more than two arms).
        When

        the observed split of debts deviates from the weights (chi-square, alpha
        0.001)

        `allocation_warning` is `true` and no `winner` is emitted. A `draft`
        answers with

        zeros and `insufficient_data`.
      parameters:
        - $ref: "#/components/parameters/PathId"
        - $ref: "#/components/parameters/ExperimentId"
      responses:
        "200":
          description: The results of the experiment.
          content:
            application/json:
              schema:
                type: object
                required:
                  - results
                properties:
                  results:
                    $ref: "#/components/schemas/ExperimentResults"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/campaigns/{id}/experiments/{experiment_id}/start:
    post:
      operationId: startCampaignExperiment
      tags:
        - campaigns
      summary: Start an A/B experiment
      description: >
        `draft` → `running`. The variants are validated again against the

        campaign as it is now (`422` with `details` if it no longer holds).

        Experiments run on voice, SMS and email campaigns whose messages or
        calls

        the platform sends itself. A preview or manual campaign

        (`details[].path = "campaign"`, «experiments need an auto-dialed
        campaign;

        preview and manual are not») is `422` here, even though creating the
        draft

        is accepted. A campaign with another experiment running, or an
        experiment

        that is not a `draft`, is `409`.
      parameters:
        - $ref: "#/components/parameters/PathId"
        - $ref: "#/components/parameters/ExperimentId"
      responses:
        "200":
          description: The experiment, now `running`.
          content:
            application/json:
              schema:
                type: object
                required:
                  - experiment
                properties:
                  experiment:
                    $ref: "#/components/schemas/Experiment"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "422":
          $ref: "#/components/responses/ExperimentUnprocessable"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/campaigns/{id}/experiments/{experiment_id}/stop:
    post:
      operationId: stopCampaignExperiment
      tags:
        - campaigns
      summary: End an A/B experiment
      description: "`running` → `ended` (terminal). Anything else → `409`."
      parameters:
        - $ref: "#/components/parameters/PathId"
        - $ref: "#/components/parameters/ExperimentId"
      responses:
        "200":
          description: The experiment, now `ended`.
          content:
            application/json:
              schema:
                type: object
                required:
                  - experiment
                properties:
                  experiment:
                    $ref: "#/components/schemas/Experiment"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /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).


        **Caller-ID comes from the campaign's pool**, resolved on every click

        with the same local-presence pick and anti-reuse cap the auto-pacer

        uses. A pool that is configured but yields no usable number right now

        (every DID quarantined or retired, the reuse cap took them all, or the

        registry could not be read) is `422` `caller_id_unavailable`: the call

        is not placed from an unvalidated number.


        **The agent's softphone is checked before the consumer is dialed**

        (core#1395). When the bridge goes through the SIP edge (a browser seat),

        core asks whether the agent's softphone is registered, and places no

        call when it cannot bridge to it:

        `409` `softphone_unregistered` — not registered yet, within 30 s of the

        check-in (retry shortly; the seat is kept);

        `409` `softphone_unreachable` — still not registered after that grace:

        the seat is checked out and the agent must check in again;

        `503` `registration_unverifiable` — the registration could not be

        checked right now (retryable; the seat is kept).


        **Who may name whom** (ADR #133, ADR #134). The `agent_id` is bound by

        the PRINCIPAL, the same rule on every verb that reads one: a `dd_`

        machine key names any agent of its tenant; a `ddw_` only its own; a

        `ddu_` login session linked to an agent seat acts as THAT seat —

        `agent_id` may be omitted (filled with the seat) or repeat it, and

        naming another agent is `403 forbidden`; a `ddu_` session with NO

        linked seat may name another agent only with `users.role =

        account_admin` — a `member` is `403 role_forbidden` — once the tenant's

        catalog key `ddu_author_role_enforced` is on (born off: until then the

        seatless session keeps the `dd_` contract on this verb).
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - debt_id
              properties:
                debt_id:
                  type: string
                  format: uuid
                agent_id:
                  type: string
                  format: uuid
                  description: The dialing agent. Required for a `dd_` key (`400` when missing); a
                    `ddu_` session linked to a seat may omit it (its seat) and
                    may not name another.
            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"
        "503":
          description: The agent's softphone registration could not be verified; no call
            was placed (retryable).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: registration_unverifiable
                  message: the agent's softphone registration could not be verified; no call was
                    placed
  /v1/dial/manual:
    post:
      operationId: manualDialNumber
      tags:
        - campaigns
      summary: Dial a typed number (dialpad), resolved to one of the tenant's accounts
      description: >
        The agent TYPES an E.164 number. It is resolved against the phones of
        the

        tenant's own accounts (RLS-scoped). Only when it matches exactly ONE

        dialable account is the call placed, through the SAME pipeline as

        `POST /v1/campaigns/{id}/dial` — the account's own campaign, the agent

        seat, the softphone check and the gated Originator (Consent, DNC, the

        destination-local calling window, Reg F frequency, state matrix) — so
        the

        outcomes of that verb apply unchanged: `200` `dialing`, `200` `blocked`

        with the gate's `reason`, `409`, `429`, `503`.


        **Fail-closed, no call is placed** (`422`, machine-readable `code`):

        `invalid_number` (not E.164), `no_account` (no dialable account of the

        tenant holds the number — a number with no account is NOT dialable),

        `ambiguous_account` (more than one account holds it), `no_campaign` (the

        account has no campaign to dial it through), `number_quarantined`.

        `503` `audit_unavailable` when the request could not be recorded, and

        `503` `resolution_unavailable` when the account lookup failed.


        Every request is audited append-only (who typed which number, and each

        block reason). **Who may name whom** is the rule of

        `POST /v1/campaigns/{id}/dial` (ADR #133, ADR #134): a `ddw_` only its

        own `agent_id` (another is `403 forbidden`), a `dd_` any agent of its

        tenant, a seated `ddu_` its own seat.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - number
              properties:
                number:
                  type: string
                  description: The typed destination, E.164 (`+13125550100`).
                agent_id:
                  type: string
                  format: uuid
                  description: The dialing agent. Required for a `dd_` key (`400` when missing); a
                    `ddw_` must name its own agent (missing or another is `403
                    forbidden`); a seated `ddu_` may omit it (its seat) and may
                    not name another.
            example:
              number: "+13125551001"
              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: dnc_listed
        "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":
          description: The typed number does not resolve to exactly one dialable account;
            no call was placed.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: no_account
                  message: no account of this tenant holds that number; no call was placed
        "429":
          $ref: "#/components/responses/Throttled"
        "502":
          $ref: "#/components/responses/DialError"
        "503":
          description: The dial was not placed and is retryable — the softphone
            registration, the audit write or the account lookup could not be
            completed.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: audit_unavailable
                  message: the dial could not be recorded, so it was not placed; retry
  /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
                  debtor_name: Ana María Pérez
                  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"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /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.


        List health (`list_health`, core#1181). `states` counts each lead under
        its FIRST block

        only, so a list with debts lacking consent AND an expired DNC snapshot
        shows one of the two.

        `list_health.signals` reads the same evaluated leads once per signal,
        none stopping the

        others: `consent`, `state` (the `us_state` resolves to a canonical
        code), `dnc` (the lists,

        their snapshots and the per-state registry) and `rnd` (Reassigned
        Numbers). A signal that

        cannot decide — a DNC list with no fresh snapshot, no RND provider
        declared, an RND without

        data or in error, a gate that crashed, a state that does not resolve —
        is `no_verificable`

        and COUNTS AS A BLOCK (`blocked` includes `no_verificable`), never as
        healthy. The cell-level

        premises ride alongside (age of each DNC snapshot, RND provider), so a
        signal whose premise

        is unverifiable says `no_verificable` even with zero leads. Additive:
        `states` and `totals`

        are unchanged.
      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
                  - list_health
                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
                  list_health:
                    type: object
                    description: Per-signal health of the evaluated leads, without short-circuit
                      (core#1181).
                    required:
                      - signals
                      - dnc_snapshots
                      - dnc_state_registries
                      - rnd_provider
                    properties:
                      signals:
                        type: object
                        required:
                          - consent
                          - state
                          - dnc
                          - rnd
                        properties:
                          consent:
                            $ref: "#/components/schemas/PreflightSignal"
                          state:
                            $ref: "#/components/schemas/PreflightSignal"
                          dnc:
                            $ref: "#/components/schemas/PreflightSignal"
                          rnd:
                            $ref: "#/components/schemas/PreflightSignal"
                      dnc_snapshots:
                        type: object
                        description: The three global DNC lists, each against the 31-day ceiling.
                        required:
                          - federal
                          - state
                          - internal_global
                        properties:
                          federal:
                            $ref: "#/components/schemas/PreflightDncSnapshot"
                          state:
                            $ref: "#/components/schemas/PreflightDncSnapshot"
                          internal_global:
                            $ref: "#/components/schemas/PreflightDncSnapshot"
                      dnc_state_registries:
                        type: object
                        description: Every declared per-state DNC registry, keyed by state code, each
                          against its own ceiling.
                        additionalProperties:
                          $ref: "#/components/schemas/PreflightDncSnapshot"
                      rnd_provider:
                        type: object
                        required:
                          - status
                          - provider
                        properties:
                          status:
                            type: string
                            enum:
                              - declared
                              - stub
                              - no_verificable
                            description: "`stub` is the development provider that answers clean without
                              asking a registry; `no_verificable` means none is
                              declared (the gate refuses every dial)."
                          provider:
                            type:
                              - string
                              - "null"
                            description: Name of the declared provider; null when none.
              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: {}
                list_health:
                  signals:
                    consent:
                      status: blocking
                      blocked: 2
                      no_verificable: 0
                      rules:
                        tcpa.consent.v1: 2
                    state:
                      status: no_verificable
                      blocked: 1
                      no_verificable: 1
                      rules:
                        federal_fallback: 1
                    dnc:
                      status: blocking
                      blocked: 1
                      no_verificable: 0
                      rules:
                        dnc.lists.v1: 1
                    rnd:
                      status: ok
                      blocked: 0
                      no_verificable: 0
                      rules: {}
                  dnc_snapshots:
                    federal:
                      status: provisioned
                      age_days: 3
                      max_age_days: 31
                      source: ftc:2026-09-23
                      record_count: 240000000
                    state:
                      status: provisioned
                      age_days: 3
                      max_age_days: 31
                      source: states:2026-09-23
                      record_count: 1200000
                    internal_global:
                      status: provisioned
                      age_days: 0
                      max_age_days: 31
                      source: platform
                      record_count: 1500
                  dnc_state_registries:
                    FL:
                      status: absent
                      age_days: null
                      max_age_days: 31
                      source: none
                      record_count: null
                  rnd_provider:
                    status: declared
                    provider: Example.RND.Provider
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /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, pool_unavailable,

        pool_unavailable_reason, no_agent_seat, no_rate, tenant_throttled,

        tenant_throttle_reason, tenant_throttle_by_reason,
        tenant_throttle_last_at,

        prompt_unavailable, prompt_unavailable_reason, overdial_disarmed,

        thirty_day_abandonment, overdial_margin, in_flight, pacing}`; SMS
        runners report

        `{sent, duplicate, blocked, failed, pending_cooldown,
        send_unavailable_reason}`.
      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
                  throttled: 0
                  pool_unavailable: 0
                  pool_unavailable_reason: null
                  no_agent_seat: 0
                  no_rate: 2
                  prompt_unavailable: 0
                  prompt_unavailable_reason: null
                  overdial_disarmed: null
                  thirty_day_abandonment: null
                  overdial_margin: null
                  tenant_throttled: 120
                  tenant_throttle_reason: exposure_limit
                  tenant_throttle_by_reason:
                    exposure_limit: 120
                  tenant_throttle_last_at: 2026-09-29T16:58:00Z
                  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"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /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"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/campaigns/{id}/abandonment:
    get:
      operationId: campaignAbandonment
      tags:
        - campaigns
      summary: Durable abandonment rate per 30-day period
      description: |
        The records of 47 CFR §64.1200(a)(7)(iii) and 16 CFR §310.4(b)(4)(iv):
        the campaign's abandonment rate for each successive 30-day period,
        anchored at its first placed dial (`anchored_at`), folded at read time
        from the append-only CDR (`call_attempts`). No counter on the call path
        feeds it. The live rate of `/v1/campaigns/{id}/stats` is the pacing
        controller's 120-second window; this one is the record.

        Measured: the outbound dials the platform placed (not the `not_placed`
        liquidation, an inbound leg, an external-transfer carrier leg or a
        `cms_import` row). Denominator `answered_live`: answered, without an
        AMD `machine` verdict. Numerator `abandoned`, by reason
        (`predicate.counts`): `no_agent` (disposition `abandoned`: answered in
        an agent campaign and no representative connected, with no seat
        reserved for the attempt), `ai_stream_lost` (the voice-AI fork was
        lost and nothing was transferred) and `agent_bridge_failed` (the same
        `abandoned` answer, but the attempt carries an `agent_id`: a seat was
        reserved and the bridge to it failed). `agent_bridge_failed` is the
        part of what `no_agent` counted until core#1383 that had a seat: the
        two causes ask for different fixes, and the split moves neither
        `abandoned` nor `rate`. An artificial-voice delivery (`delivered`,
        `ai_conversation`) is not an abandon (§64.1200(a)(7)(ii),
        §310.4(b)(1)(v)(C)); a voice-AI handoff that found no seat is
        reported apart as `handoff_failed` and is not in the rate
        (`predicate.reported_apart`). Also reported apart, since core#1426:
        `late_bridge`, a live answer not counted as abandoned whose consumer
        leg was bridged MORE than 2 s after `answered_at` (§310.4(b)(1)(iv):
        "within two (2) seconds of the person's completed greeting";
        `answered_at` stands in for the end of the greeting, so the delay is
        measured long, never short). A transfer the voice-AI or the IVR
        ordered is not counted, nor is a call written before the bridge
        instant was recorded. `exceeds_ceiling` is "more than three
        percent", decided in integers; `rate` is `null` with no live answer.
        Rows written before core 0.73.0 keep the disposition they were born
        with, so an agentless delivery of that era reads `abandoned` and
        counts. A period with no dial is not listed.

        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`.
      parameters:
        - $ref: "#/components/parameters/PathId"
        - $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/AbandonmentReport"
              example:
                campaign_id: 3f2c1b0a-9d8e-4c7b-a6f5-4e3d2c1b0a99
                measured_at: 2026-09-29T22:00:00.000000Z
                period_days: 30
                ceiling_percent: 3
                anchored_at: 2026-08-01T15:00:00.000000Z
                predicate:
                  counts:
                    - no_agent
                    - ai_stream_lost
                    - agent_bridge_failed
                  reported_apart:
                    - handoff_failed
                    - late_bridge
                periods:
                  - index: 0
                    starts_at: 2026-08-01T15:00:00.000000Z
                    ends_at: 2026-08-31T15:00:00.000000Z
                    complete: true
                    placed: 1200
                    answered: 410
                    answered_machine: 60
                    answered_live: 350
                    abandoned: 7
                    abandoned_by_reason:
                      no_agent: 5
                      ai_stream_lost: 0
                      agent_bridge_failed: 2
                    handoff_failed: 0
                    late_bridge: 4
                    rate: 0.02
                    exceeds_ceiling: false
            text/csv:
              schema:
                type: string
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/campaigns/{id}/email-skips:
    get:
      operationId: campaignEmailSkips
      tags:
        - campaigns
        - email
      summary: Why an e-mail campaign did not send to a lead
      description: >
        The record of every lead the e-mail runner DISCARDED without sending and

        without a compliance block, and of every refusal that HALTED its tick: a

        running `email_blast` campaign with nothing in `/v1/stats/blocked` and

        nothing in `/v1/email/messages` is explained here. One append-only row
        per (campaign, debt, reason, UTC day) — the

        runner offers the same debt again every lap and that is one row per day.

        The numbers are derived from the rows at read time; nothing is counted
        on

        the send path.


        `reasons` lists EVERY reason of the closed vocabulary (zeros included):

        `debt_not_found`, `debt_not_dialable` (the debt left the dialable
        states),

        `no_sendable_address` (no contact has an address with valid provenance
        that

        is not suppressed), `email_address_invalid` and `email_debt_unresolved`
        (the

        sender refused that lead only), and the `throttled_*` reasons
        (core#2105):

        the sender answered `{:throttled, motivo}`, a refusal that is the same
        for every

        lead, so the tick halts and the debt is offered again —
        `throttled_plan_inactive`,

        `throttled_rate_limit`, `throttled_provider_open`, `throttled_spend_cap`
        and

        `throttled_no_rate` (no e-mail rate is configured for the sending
        region, so

        nothing is sent: fail-closed) and `throttled_synthetic_tenant`
        (core#2251: the tenant

        is a synthetic demo tenant, which originates no outbound traffic). For a
        `throttled_*` reason `debt_id` is the lead the

        refusal met, and the row is written when the reason changes or the UTC
        day does,

        not once per tick. `leads` is the number of distinct debts

        discarded for the reason; `days` the UTC days with a row; `first_at` /

        `last_at` are `null` with no row. `total_leads` counts a debt discarded

        for two different reasons once. `samples` are the latest rows, newest

        first, optionally narrowed with `reason`. No address is ever served.


        Read-only and tenant-scoped (a `dd_` key or a dashboard login): a
        campaign

        of another tenant is `404`, never an empty `200`.
      parameters:
        - $ref: "#/components/parameters/PathId"
        - name: reason
          in: query
          description: Narrow `samples` to one reason. Omitted = every reason. `reasons`
            is never narrowed.
          schema:
            type: string
            enum:
              - debt_not_found
              - debt_not_dialable
              - no_sendable_address
              - email_address_invalid
              - email_debt_unresolved
              - throttled_plan_inactive
              - throttled_rate_limit
              - throttled_provider_open
              - throttled_spend_cap
              - throttled_no_rate
              - throttled_synthetic_tenant
        - name: limit
          in: query
          description: Size of `samples` (default 20, max 100).
          schema:
            type: integer
            default: 20
            maximum: 100
            minimum: 1
      responses:
        "200":
          description: Per-reason numbers and the latest rows.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EmailLeadSkips"
              example:
                campaign_id: 3f2c1b0a-9d8e-4c7b-a6f5-4e3d2c1b0a99
                total_leads: 3
                reasons:
                  - reason: debt_not_found
                    leads: 0
                    days: 0
                    first_at: null
                    last_at: null
                  - reason: debt_not_dialable
                    leads: 1
                    days: 1
                    first_at: 2026-10-10T15:02:00.000000Z
                    last_at: 2026-10-10T15:02:00.000000Z
                  - reason: no_sendable_address
                    leads: 2
                    days: 2
                    first_at: 2026-10-09T15:00:00.000000Z
                    last_at: 2026-10-10T15:00:00.000000Z
                  - reason: email_address_invalid
                    leads: 0
                    days: 0
                    first_at: null
                    last_at: null
                  - reason: email_debt_unresolved
                    leads: 0
                    days: 0
                    first_at: null
                    last_at: null
                  - reason: throttled_plan_inactive
                    leads: 0
                    days: 0
                    first_at: null
                    last_at: null
                  - reason: throttled_rate_limit
                    leads: 0
                    days: 0
                    first_at: null
                    last_at: null
                  - reason: throttled_provider_open
                    leads: 0
                    days: 0
                    first_at: null
                    last_at: null
                  - reason: throttled_spend_cap
                    leads: 0
                    days: 0
                    first_at: null
                    last_at: null
                  - reason: throttled_no_rate
                    leads: 0
                    days: 0
                    first_at: null
                    last_at: null
                  - reason: throttled_synthetic_tenant
                    leads: 0
                    days: 0
                    first_at: null
                    last_at: null
                samples:
                  - debt_id: 8a1d5c0e-2b7f-4e49-9c3a-6f0b1d2e3a44
                    reason: debt_not_dialable
                    occurred_at: 2026-10-10T15:02:00.000000Z
                  - debt_id: 5e9f7a10-3c4d-4b8e-a1f2-7d6c5b4a3921
                    reason: no_sendable_address
                    occurred_at: 2026-10-10T15:00:00.000000Z
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /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
        - name: amd_verdicts
          in: query
          description: Comma-separated AMD verdicts matched against each debt's latest
            placed attempt.
          schema:
            type: string
          example: machine,human
        - name: segment_id
          in: query
          description: >
            A saved segment (`/v1/rewind-segments`) to use INSTEAD of the loose

            filters; combining it with any of them is `400`. Resolved
            server-side

            and re-evaluated now.
          schema:
            type: string
            format: uuid
      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"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
    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/RewindRequest"
            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"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/campaigns/{id}/rewind:
    post:
      operationId: rewindCampaign
      tags:
        - campaigns
      summary: Re-queue matching exhausted debts
      description: >
        Sets THIS campaign's `requeued_at` watermark on each matching debt (the

        campaign's lead feed treats them as never-attempted; no other campaign's

        watermark moves) and resurrects `closed` → `open` — except a `closed`
        debt

        that is also in another campaign's population: its state belongs to
        every

        campaign that holds it, so it stays `closed` and is counted in

        `kept_closed`. `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` narrows by the latest placed

        attempt's AMD verdict; an unknown verdict answers `422`.

        `segment_id` names a saved segment (`/v1/rewind-segments`) in place of
        the

        loose filters.
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/RewindRequest"
            example:
              max_attempts: 1
              keep_scheduled_callbacks: true
      responses:
        "200":
          description: Rewind applied.
          content:
            application/json:
              schema:
                type: object
                required:
                  - requeued
                  - callbacks_cancelled
                  - kept_closed
                properties:
                  requeued:
                    type: integer
                  callbacks_cancelled:
                    type: integer
                  kept_closed:
                    type: integer
                    description: >
                      Matching `closed` debts left `closed` because another
                      campaign

                      also has them in its population. Their watermark in this

                      campaign moved anyway.
              example:
                requeued: 9
                callbacks_cancelled: 0
                kept_closed: 0
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/Unprocessable"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /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"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/consents/{phone}:
    parameters:
      - name: phone
        in: path
        required: true
        description: >
          The consumer's number: E.164 (`+13125550100`, with the `+`
          percent-encoded as `%2B`

          or not) or a bare NANP number. It travels in the path, so it reaches
          the access logs

          of any proxy in front of the API; the response never echoes it back
          whole.
        schema:
          type: string
    put:
      operationId: putConsent
      tags:
        - compliance
      summary: Record the consent the tenant holds for a number
      description: >
        Records, with its proof, the prior express consent of a consumer to be
        called

        (core#2552). Until this route the only way a consent reached the
        compliance gates

        was the consumer answering `START` by SMS; the consent gate and the
        artificial-voice

        gate (AI, TTS, IVR, survey) read what is written here. **Tenant
        management only**: a

        `dd_` key or an `account_admin` login; a `member` login is `403
        role_forbidden`.


        Every write needs `channel` (`voice`, `sms` or `any`), `consented_at`
        (ISO 8601 with

        an offset), `source` (where the consent was obtained) and `evidence_ref`
        (where the

        proof lives: a recording id, a signed form, a URL); `text_version` is
        optional.

        Upsert by number: repeating the call replaces the row, and each write is
        kept in the

        append-only history of opt-outs and sealed in the compliance chain in
        the same

        transaction.


        `consented_at` is validated, because the reassigned-numbers check
        measures its

        lookback from it and a later date than the real one relaxes that check:
        it cannot be

        in the future, and it cannot be later than the day the number was first
        registered in

        the tenant's contacts (`422`). A number whose consent was revoked takes
        a new grant

        only when `consented_at` is later than the revocation (`409
        consent_revoked`).


        The consent gate is not the only gate: DNC, calling hours, Reg F and the
        rest keep

        deciding every call after it. A consent does not change the line type of
        a number.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - channel
                - consented_at
                - source
                - evidence_ref
              additionalProperties: false
              properties:
                channel:
                  type: string
                  enum:
                    - voice
                    - sms
                    - any
                consented_at:
                  type: string
                  format: date-time
                  description: When the consumer gave the consent. Not in the future.
                source:
                  type: string
                  maxLength: 200
                  description: Where the consent was obtained (`web_form`, `ivr_recording`, …).
                evidence_ref:
                  type: string
                  maxLength: 2000
                  description: Where the proof lives; free text or a URL.
                text_version:
                  type: string
                  maxLength: 200
                  description: Version of the consent wording the consumer accepted.
            example:
              channel: voice
              consented_at: 2026-01-15T14:30:00Z
              source: web_form
              evidence_ref: https://forms.example.com/submissions/8841
              text_version: tcpa-v3
      responses:
        "200":
          description: Recorded. The number comes back masked.
          content:
            application/json:
              schema:
                type: object
                required:
                  - consent
                properties:
                  consent:
                    $ref: "#/components/schemas/ConsentRecord"
              example:
                consent:
                  number: "***0100"
                  status: granted
                  channel: voice
                  consented_at: 2026-01-15T14:30:00.000000Z
                  source: web_form
                  evidence_ref: https://forms.example.com/submissions/8841
                  text_version: tcpa-v3
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >
            `role_forbidden`: a dashboard login without the `account_admin`
            role;

            `forbidden`: a widget token or a suspended tenant.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: role_forbidden
                  message: changing compliance configuration takes a dd_ key or a dashboard login
                    with the account_admin role
        "409":
          description: >
            `consent_revoked`: the number revoked its consent and `consented_at`
            is not later

            than the revocation (or the revocation carries no date). The
            consumer can lift it

            with a `START`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: consent_revoked
                  message: the number revoked its consent at 2026-03-01T10:00:00Z; a new consent
                    must carry a consented_at later than that
        "422":
          $ref: "#/components/responses/Unprocessable"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
    delete:
      operationId: revokeConsent
      tags:
        - compliance
      summary: Revoke the consent of a number
      description: >
        Records the revocation of a consumer's consent (Reg F: any reasonable
        means).

        **Tenant management only**, like the write. It always applies, whatever
        the state

        on file, and it also applies to a number with no consent row: from then
        on the

        consent gate refuses the number (`consent_revoked`) until a consent
        later than the

        revocation is recorded. Idempotent. Nothing is asked of the caller:
        refusing a

        revocation for a missing field would be the wrong direction. The
        revocation is kept

        in the append-only history and sealed in the compliance chain.
      responses:
        "200":
          description: Revoked.
          content:
            application/json:
              schema:
                type: object
                required:
                  - consent
                properties:
                  consent:
                    type: object
                    required:
                      - number
                      - status
                      - revoked_at
                    properties:
                      number:
                        type: string
                        description: Masked to its last 4 digits.
                      status:
                        type: string
                        enum:
                          - revoked
                      revoked_at:
                        type: string
                        format: date-time
              example:
                consent:
                  number: "***0100"
                  status: revoked
                  revoked_at: 2026-03-01T10:00:00.000000Z
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: "`role_forbidden` or `forbidden`, as in the write."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "422":
          $ref: "#/components/responses/Unprocessable"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/consents/import:
    post:
      operationId: importConsents
      tags:
        - compliance
      summary: Record consents in bulk (CSV)
      description: >
        The write of `PUT /v1/consents/{phone}` for many numbers at once, from a
        `text/csv`

        body. **Tenant management only.** One row per number:

        `number,channel,consented_at,source,evidence_ref[,text_version]`; a
        header row is

        optional, blank lines and lines starting with `#` are skipped, and a
        field with a comma

        goes in double quotes. Each row is validated exactly like the single
        write

        (including the date and the standing revocation).


        Malformed rows are REPORTED, not fatal, and the direction is the safe
        one: a refused

        row leaves its number WITHOUT consent, so the gates keep blocking it.
        Rejected values

        come back MASKED; the line number identifies the row.


        Limits: 1 000 000 bytes of body, 1 000 data rows, at most 100 entries in
        `rejected`

        (`rejected_count` always carries the true total). Each row is its own
        transaction, so

        the import is throttled like the debt imports (`429`, see below).
      requestBody:
        required: true
        content:
          text/csv:
            schema:
              type: string
            example: |
              number,channel,consented_at,source,evidence_ref,text_version
              +13125550100,voice,2026-01-15T14:30:00Z,web_form,https://forms.example.com/submissions/8841,tcpa-v3
              3125550101,any,2025-11-02T09:00:00Z,ivr_recording,rec-2025-11-02-77123,
      responses:
        "200":
          description: >
            Upload outcome. A 200 does NOT mean every row was recorded;
            `rejected_count` is

            authoritative.
          content:
            application/json:
              schema:
                type: object
                required:
                  - received
                  - recorded
                  - rejected_count
                  - rejected
                properties:
                  received:
                    type: integer
                    description: Data rows the document offered (header and blank lines excluded).
                  recorded:
                    type: integer
                    description: Rows written.
                  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 number, masked to its last 4 characters.
                        reason:
                          type: string
                          enum:
                            - not_e164
                            - duplicate
                            - invalid_channel
                            - missing_source
                            - source_too_long
                            - missing_evidence_ref
                            - evidence_ref_too_long
                            - text_version_too_long
                            - invalid_consented_at
                            - consented_at_in_future
                            - consented_at_after_registration
                            - revoked_not_lifted
              example:
                received: 3
                recorded: 2
                rejected_count: 1
                rejected:
                  - line: 4
                    value: "***5551"
                    reason: missing_evidence_ref
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: "`role_forbidden` or `forbidden`, as in the single write."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "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"
        "429":
          $ref: "#/components/responses/ImportThrottled"
  /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.


        There is no `phone` parameter: a number in the query string would end up

        in ingress access logs, so `?phone=` is refused with a 400 whose code is

        `phone_not_in_query`. Search by phone with `POST /v1/debts/search`,
        which

        takes it in the body.

        `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.


        `campaign_id` selects the campaign's POPULATION — the accounts whose own

        `campaign_id` is that campaign, plus the members of any list associated

        with it (`/v1/debt-lists`) — each account once. The `campaign_id` field

        of each row is still the account's own campaign (its direct slot).


        `campaign_id`, `state`, `debt_type`, `us_state`, `min_amount_cents` and

        `max_amount_cents` are the SEGMENT criteria (ADR #221): a closed
        vocabulary

        validated in one place (`Dialer.Debts.Segment`) and combined with AND. A

        value outside the vocabulary is a 400, never an empty page. Segments
        select

        accounts by attributes only: `phone`, `consumer_ref`, `external_ref` and

        `debtor_name` are never segment criteria (this listing keeps its own
        exact

        searches by the first three; the debtor's name is not searchable). A
        query

        parameter this endpoint does not know is ignored, as before.


        `include=dialability` (ADR #269) adds to every row `dialability`: why
        the

        account is not dialable and each phone's consent, evaluated now by the
        SAME

        gates the dialer runs (`DebtDialability`). It is opt-in and costs a
        bounded

        number of reads per PAGE, not per row, so the page is capped at 50: a

        larger `limit` is a 400, never a silent cut. Without `include` the body
        is

        byte-for-byte the one it always was (the field is absent, not `null`).

        `dialable: true` never means "a call will be placed": see

        `DebtDialability.not_evaluated_gates`. An unknown `include` value is a
        400.
      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: state[]
          in: query
          description: >
            Several debt states at once (`state[]=open&state[]=disputed`): an
            account

            matches when its state is ANY of them. Repeats are ignored; an empty
            or

            unknown entry is a 400.
          style: form
          explode: true
          schema:
            type: array
            minItems: 1
            items:
              type: string
              enum:
                - open
                - in_collection
                - promise_to_pay
                - paid
                - settled
                - disputed
                - closed
        - name: debt_type
          in: query
          description: >
            Exact debt type. An unknown value is a 400, not an empty page. Part
            of the

            closed segment vocabulary (`Dialer.Debts.Segment`, ADR #221).
          schema:
            type: string
            enum:
              - other
              - student_loan
        - name: debt_type[]
          in: query
          description: Several debt types at once
            (`debt_type[]=other&debt_type[]=student_loan`); matches ANY.
          style: form
          explode: true
          schema:
            type: array
            minItems: 1
            items:
              type: string
              enum:
                - other
                - student_loan
        - name: us_state
          in: query
          description: >
            US state of the account's PRIMARY contact: a USPS two-letter code in
            upper

            case (state, possession or military code;
            `Dialer.Compliance.UsStates`).

            Anything else (`tx`, `XX`) is a 400, not an empty page. An account
            with no

            contact never matches.
          schema:
            type: string
            pattern: ^[A-Z]{2}$
            example: TX
        - name: us_state[]
          in: query
          description: Several US states at once (`us_state[]=TX&us_state[]=NY`); matches
            ANY.
          style: form
          explode: true
          schema:
            type: array
            minItems: 1
            items:
              type: string
              pattern: ^[A-Z]{2}$
        - 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: 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). With `include=dialability` the
            maximum

            is 50 and a larger value is a 400.
          schema:
            type: integer
            default: 50
            maximum: 200
            minimum: 1
        - $ref: "#/components/parameters/DebtsInclude"
        - $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"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/debts/search:
    post:
      operationId: searchDebts
      tags:
        - debts
      summary: Search accounts with the filter in the request body (phone included)
      description: >
        Same filters, pagination, tenant scoping (RLS) and masked projection as

        `GET /v1/debts`, with the filter in a JSON body. It exists so a debtor's

        phone number never travels in the URL (ingress and proxy access logs). A

        search is a read: no state changes.


        `phone` searches by deterministic blind index and requires a normalized

        E.164 (`+13125550100`); anything else is a 400, not an empty page. A
        body

        key outside the schema is a 400. An empty body `{}` returns the first
        page

        of the whole portfolio.


        `"include": ["dialability"]` adds the per-account dialability verdict,
        with

        the same meaning, cap (page of 50) and cost as on `GET /v1/debts`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/DebtSearchRequest"
      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"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/debts/import:
    post:
      operationId: importDebts
      tags:
        - debts
      summary: Bulk JSON or CSV lead import (idempotent)
      description: >
        Accepts the existing JSON envelope or a `multipart/form-data` upload.

        The multipart form carries `file` (`text/csv`), `mapping` (a JSON object

        whose keys are import targets and whose values are CSV header names),

        and optional `separator` (`comma`, `semicolon`, or `tab`; default

        `comma`). Debt targets: JSON field names minus `state`. A mapped

        `last_conversation_at` column goes through the same validation as the
        JSON

        field (RFC 3339 with a UTC offset, never in the future) and seeds the
        same

        `cms_import` conversation; a bad cell fails only its row, and a blank
        cell

        is the same as omitting the field. A single contact may

        be mapped with `contact.phone_e164`, `contact.line_type`,

        `contact.timezone`, `contact.us_state`, `contact.city`,

        `contact.postal_code`, `contact.is_primary`, or

        `contact.consumer_ref`. The debtor's name maps to `debtor_name`
        (optional,

        at most 200 characters, encrypted at rest; see `DebtImportItem`).

        A mapped `delinquent_since` column (a `YYYY-MM-DD` date) goes through
        the

        same validation as the JSON field; a bad cell fails only its row.


        With `?mapping_id=` the multipart form carries only `file`: the stored

        template supplies the mapping and the separator, and sending `mapping`

        or `separator` as well is refused with `422`. A template that maps one

        field from two columns, a field the CSV importer does not know, or

        `options.default_currency` (the upload has no currency default),

        answers `422` and imports nothing.


        CSV is UTF-8; a leading BOM is accepted. The default file limit is

        4 000 000 bytes and the default row limit is 10 000 data rows. They are

        governed per-tenant by the R19 catalog keys `debt_csv_max_bytes` and

        `debt_import_max_rows`; the latter applies to JSON too. Parsing is
        fail-closed:

        malformed CSV, invalid encoding, missing/unknown multipart fields,

        missing mapped headers, or an unsupported separator imports nothing

        and answers `422`; an oversized field answers `413`. The uploaded file

        stays in memory and is never written to disk or logged.


        After parsing and mapping, both representations enter the SAME item

        scrub, normalisation and import pipeline. Per-item fail-soft, the row
        limit

        above 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. Failed rows return only their

        zero-based index and reason; no CSV cell value is echoed.


        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.
      parameters:
        - $ref: "#/components/parameters/ImportMappingIdQuery"
        - $ref: "#/components/parameters/ImportMappingVersionQuery"
        - $ref: "#/components/parameters/ImportMappingImportIdQuery"
      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
                  debtor_name: Ana María Pérez
                  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
          multipart/form-data:
            schema:
              type: object
              required:
                - file
              description: |
                `mapping` is required unless the request names `?mapping_id=`,
                in which case neither `mapping` nor `separator` may be sent.
              additionalProperties: false
              properties:
                file:
                  type: string
                  contentMediaType: text/csv
                  format: binary
                mapping:
                  type: string
                  contentMediaType: application/json
                  description: JSON object of import target to exact CSV header.
                  examples:
                    - '{"external_ref":"account","consumer_ref":"consumer","contact.phone_e164":"phone"}'
                separator:
                  type: string
                  enum:
                    - comma
                    - semicolon
                    - tab
                  default: comma
      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
                  mapping:
                    type: object
                    description: "Present only when the request named `mapping_id`: the template
                      version recorded as used."
                    required:
                      - id
                      - version
                    properties:
                      id:
                        type: string
                        format: uuid
                      version:
                        type: integer
              example:
                imported: 1
                failed: []
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          description: The CSV file or another multipart field exceeds its byte ceiling.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "422":
          $ref: "#/components/responses/Unprocessable"
        "429":
          $ref: "#/components/responses/ImportThrottled"
  /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"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/consumers/{id}:
    get:
      operationId: getConsumer
      tags:
        - consumers
      summary: Debtor card (debts, DNC / cease-and-desist, Reg F counter, next
        permitted window)
      description: >
        The card of ONE debtor, keyed by `consumer_ref` — the string core
        already

        counts Reg F under (`{tenant, consumer_ref, debt_key}`). core has no

        `consumers` entity and this adds none: the path `id` is the
        `consumer_ref`

        the CMS sent on `POST /v1/debts/import`, so the card and the Reg F gate

        cannot disagree about who "the same debtor" is. `""` is the absence of a

        consumer, never a card.


        Everything is DERIVED at request time from the append-only sources

        (`call_attempts`, `conversations`, `cease_and_desist`, the DNC lists);
        no

        counter or cache is added. Each debt is the public projection of

        `GET /v1/debts` (masked phone, no `account_number`) plus the debtor's
        name

        (`debtor_name`, core#1529), its Reg F counter and its own next window.
        No phone number is published: DNC state is counts

        per list.


        **`window` / `next_window`** answer "when is the next time a call to
        this

        debtor is permitted?", in UTC and in the destination's local time. They

        come from running the REAL gates listed in `evaluated_gates` over a

        read-only snapshot, so the instant given is one the dialer itself would

        permit. They do NOT cover `not_evaluated_gates` (consent, AI /
        artificial

        voice, campaign state, DNC provisioning proofs), which depend on the

        campaign or on the dial. The answer is fail-closed: `permitted_now` /

        `scheduled` only when a gate run positively permitted that instant;

        `undecidable` (with `reason`) when it cannot decide — unresolvable

        destination time zone, no contacts, too many numbers to check, a gate
        that

        failed, nothing permitted inside the 9-day horizon — and `suppressed` /

        `not_dialable` when the debtor must not be called.


        A consumer who exists only in another tenant is `404`, indistinguishable

        from one that does not exist.
      parameters:
        - name: id
          in: path
          required: true
          description: The debtor's `consumer_ref` (1 to 128 characters, not blank,
            without NUL bytes).
          schema:
            type: string
            minLength: 1
            maxLength: 128
      responses:
        "200":
          description: The card.
          content:
            application/json:
              schema:
                type: object
                required:
                  - consumer
                properties:
                  consumer:
                    $ref: "#/components/schemas/ConsumerCard"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /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"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/debts/{id}/messages:
    get:
      operationId: listDebtMessages
      tags:
        - debts
      summary: What was sent to a debt, through which channel
      description: >
        Unified read per debt over the SMS and email ledgers (core#1772). It is
        a

        read-model DERIVED at read time (ADR #218): no materialized view. Each
        entry

        is an outbound ORIGINAL; `status` is the current one (latest
        transition).

        Windowed (`from`/`to`, at most 31 days, default the trailing 31) and

        keyset-paginated (default 50, max 200); `channel` narrows to one ledger.
      parameters:
        - $ref: "#/components/parameters/PathId"
        - $ref: "#/components/parameters/FromFilter"
        - $ref: "#/components/parameters/ToFilter"
        - name: channel
          in: query
          schema:
            type: string
            enum:
              - sms
              - email
        - $ref: "#/components/parameters/LimitParam"
        - $ref: "#/components/parameters/CursorParam"
      responses:
        "200":
          description: One page of sent messages, newest first.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DebtMessagePage"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /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: 5
                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: []
                suppression_events: []
                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"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/debts/{id}/defense-packet/archives:
    post:
      operationId: archiveDefensePacket
      tags:
        - debts
      summary: Archive the Litigation Defense Packet (WORM store + RFC 3161 timestamp)
      description: >
        Builds the packet exactly as `GET
        /v1/debts/{id}/defense-packet?format=zip`

        does, time-stamps the SHA-256 of the ZIP at the configured RFC 3161

        authority, writes the ZIP to the retention-locked archive store and
        reads

        the retention back before recording ONE append-only attestation row. The

        row holds what the store ANSWERED on the read-back (`retention_mode`,

        `retain_until`, `version_id`), never what was asked. Same permission as
        the

        `GET` of the packet. The body is optional and its only key is

        `retention_days`; any other key is a `400`.


        Retention: the store seals the object for the platform default

        (`DEFENSE_RETENTION_DAYS`, a deployment value that must be set to turn
        the

        feature on). A tenant can only LENGTHEN it: `retention_days` below the
        default

        is a `422`, and so is one above `DEFENSE_RETENTION_MAX_DAYS` (when that
        is not

        set the tenant cannot lengthen at all). Nobody can shorten a retention,
        and

        changing the default only affects archives created afterwards. The
        extension

        only counts for a NEW archive: bytes already archived answer `200` with
        the

        existing row and its original `retain_until`.


        Fail-closed: if the store, the authority or the read-back fails, NO row
        is

        written and the response is a `503` that says nothing was archived (an

        object the store accepted before the failure is an orphan that the
        sweeper

        removes). Idempotent by CONTENT: archiving bytes already archived for
        this

        debt answers `200` with the existing row and does not call the authority
        or

        the store again. Two calls whose ZIP differs (the packet is generated at

        second resolution) are two archives. Capped per debt and UTC day

        (`DEFENSE_ARCHIVE_MAX_PER_DEBT_PER_DAY`, default 5): over the cap
        answers `429` with

        `Retry-After` until UTC midnight. Every emission writes one entry to the

        tenant audit log.


        With `DEFENSE_ARCHIVE_ENABLED` off, the three archive routes answer the

        same `404` as a route that does not exist.
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                retention_days:
                  type: integer
                  minimum: 1
                  description: Retention to seal this archive with, in days. At least the platform
                    default and at most the platform maximum; omitted means the
                    default.
            example:
              retention_days: 365
      responses:
        "200":
          description: These exact bytes were already archived; the existing attestation.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DefensePacketArchiveResponse"
        "201":
          description: Archived; the new attestation.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DefensePacketArchiveResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/Unprocessable"
        "429":
          description: >
            The per-debt daily archive cap is reached (`code`
            `archive_daily_cap`).

            `Retry-After` is the number of seconds until UTC midnight. This
            operation

            can ALSO answer the per-key quota `429` (`rate_limited`).
          headers:
            Retry-After:
              $ref: "#/components/headers/RetryAfter"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: archive_daily_cap
                  message: the daily archive cap for this debt is reached; retry after the
                    interval in Retry-After
        "503":
          description: >
            Nothing was archived (`code` `archive_unavailable`): archiving is
            not

            configured (retention period, store or time-stamp authority unset),
            or

            the authority, the store or the retention read-back failed. Safe to
            retry.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: archive_unavailable
                  message: the archive store did not confirm the retention; nothing was archived
    get:
      operationId: listDefensePacketArchives
      tags:
        - debts
      summary: List the archived Litigation Defense Packets of a debt
      description: >
        The attestations of this debt, newest first. The RFC 3161 token is not

        inlined: it ships in the attestation kit. Same permission as the `GET`
        of the

        packet.
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: The attestations (empty list when nothing was archived).
          content:
            application/json:
              schema:
                type: object
                required:
                  - archives
                properties:
                  archives:
                    type: array
                    items:
                      $ref: "#/components/schemas/DefensePacketArchive"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/debts/{id}/defense-packet/archives/{aid}/attestation:
    get:
      operationId: getDefensePacketAttestation
      tags:
        - debts
      summary: Attestation kit of an archived Litigation Defense Packet
      description: >
        Everything a third party needs to check the archive without trusting
        this

        platform: the attestation row (`attestation.json`), the RFC 3161
        time-stamp

        as a base64 DER `TimeStampResp` (`artifact.tsr` once decoded) and a
        short-lived

        pre-signed URL of the archived ZIP (the exact `version_id`). To verify:

        `sha256sum` of the downloaded ZIP must equal
        `attestation.artifact_sha256`,

        and `openssl ts -verify -digest <that sha256> -in artifact.tsr` must
        accept

        it against the authority chain. The URL is minted per request and
        expires

        at `artifact.expires_at`. Same permission as the `GET` of the packet.
      parameters:
        - $ref: "#/components/parameters/PathId"
        - name: aid
          in: path
          required: true
          description: The attestation id (`archives[].id`).
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: The attestation kit.
          content:
            application/json:
              schema:
                type: object
                required:
                  - attestation
                  - tsr
                  - artifact
                properties:
                  attestation:
                    $ref: "#/components/schemas/DefensePacketArchive"
                  tsr:
                    type: string
                    format: byte
                    description: The RFC 3161 time-stamp as a DER `TimeStampResp` (status granted),
                      base64.
                  artifact:
                    type: object
                    required:
                      - url
                      - expires_at
                    properties:
                      url:
                        type: string
                        format: uri
                      expires_at:
                        type: string
                        format: date-time
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
        "503":
          description: |
            No archive store is configured, so no URL can be minted
            (`code` `archive_unavailable`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: archive_unavailable
                  message: "archiving is not configured: no archive store is set; nothing was
                    archived"
  /v1/import-mappings:
    get:
      operationId: listImportMappings
      tags:
        - import-mappings
      summary: The tenant's CSV import templates, each with its current version
      description: |
        By name, the live ones; `?include_archived=true` adds the archived. Each
        template comes with its CURRENT version. Capped at 2 000.
      parameters:
        - name: include_archived
          in: query
          required: false
          schema:
            type: boolean
            default: false
      responses:
        "200":
          description: The templates.
          content:
            application/json:
              schema:
                type: object
                required:
                  - import_mappings
                properties:
                  import_mappings:
                    type: array
                    items:
                      $ref: "#/components/schemas/ImportMapping"
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      operationId: createImportMapping
      tags:
        - import-mappings
      summary: Create an import template (version 1)
      description: >
        `name` is trimmed, not blank, at most 200 characters and unique among
        the

        tenant's LIVE templates (`409`). `mapping` is an ORDERED list of

        `{column, field}`: `column` is the CSV header as the file spells it
        (trimmed,

        at most 200 characters; two entries may not differ only by case or
        spaces),

        `field` the target (`a-z`, digits and `_`, at most 64, with one optional

        `group.name` dot). The server checks the

        SHAPE of `field`, not a closed vocabulary: each consumer of a template
        fails

        closed on a field it does not know. At least one entry must map to a
        field

        other than `skip`. `options` accepts `separator` (`comma`, `semicolon`,

        `tab`; default `comma`) and `default_currency` (three uppercase
        letters);

        any other key is refused. A tenant holds at most 500 live templates
        (`422`).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ImportMappingWrite"
            example:
              name: Cartera Q3 — export CMS
              mapping:
                - column: Cuenta
                  field: external_ref
                - column: Teléfono
                  field: phone_primary
                - column: Notas
                  field: skip
              options:
                separator: semicolon
                default_currency: USD
      responses:
        "201":
          description: The template, at version 1.
          content:
            application/json:
              schema:
                type: object
                required:
                  - import_mapping
                properties:
                  import_mapping:
                    $ref: "#/components/schemas/ImportMapping"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "409":
          $ref: "#/components/responses/Conflict"
        "422":
          $ref: "#/components/responses/Unprocessable"
  /v1/import-mappings/{id}:
    get:
      operationId: getImportMapping
      tags:
        - import-mappings
      summary: One import template, at its current version
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: The template (archived ones too, flagged `archived`).
          content:
            application/json:
              schema:
                type: object
                required:
                  - import_mapping
                properties:
                  import_mapping:
                    $ref: "#/components/schemas/ImportMapping"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
    patch:
      operationId: updateImportMapping
      tags:
        - import-mappings
      summary: Rename a template, or edit it into a NEW version
      description: >
        Send at least one of `name`, `mapping`, `options`. A rename changes the

        header only. Changing `mapping` or `options` appends version n+1 (the
        one

        omitted is carried over from the current) and leaves version n exactly
        as

        it was: an import may have used it. An edit that changes nothing writes
        no

        version. `base_version` is optional and, when sent, must be the current

        version — otherwise `409` and nothing is written, so two people editing
        the

        same template do not overwrite each other unnoticed. An archived
        template

        cannot be edited (`409`); a template holds at most 1 000 versions
        (`422`).
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                  maxLength: 200
                mapping:
                  $ref: "#/components/schemas/ImportMappingColumns"
                options:
                  $ref: "#/components/schemas/ImportMappingOptions"
                base_version:
                  type: integer
                  minimum: 1
              additionalProperties: false
            example:
              base_version: 1
              options:
                separator: comma
      responses:
        "200":
          description: The template, at its current version after the edit.
          content:
            application/json:
              schema:
                type: object
                required:
                  - import_mapping
                properties:
                  import_mapping:
                    $ref: "#/components/schemas/ImportMapping"
        "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"
    delete:
      operationId: archiveImportMapping
      tags:
        - import-mappings
      summary: Archive a template
      description: >
        Templates are never deleted: the versions an import used are evidence.

        Archiving frees the name for a new template and takes the template out
        of

        the default list; it can no longer be edited or named by an import.

        Idempotent.
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "204":
          description: The template is archived.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
  /v1/import-mappings/{id}/versions:
    get:
      operationId: listImportMappingVersions
      tags:
        - import-mappings
      summary: Every version of a template, oldest first
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: The versions (capped at 1 000).
          content:
            application/json:
              schema:
                type: object
                required:
                  - versions
                properties:
                  versions:
                    type: array
                    items:
                      $ref: "#/components/schemas/ImportMappingVersion"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
  /v1/import-mappings/{id}/versions/{version}:
    get:
      operationId: getImportMappingVersion
      tags:
        - import-mappings
      summary: One version of a template
      parameters:
        - $ref: "#/components/parameters/PathId"
        - name: version
          in: path
          required: true
          description: The version number (>= 1). Unknown, malformed or another tenant's
            reads as `404`.
          schema:
            type: integer
            minimum: 1
      responses:
        "200":
          description: The version, exactly as it was written.
          content:
            application/json:
              schema:
                type: object
                required:
                  - version
                properties:
                  version:
                    $ref: "#/components/schemas/ImportMappingVersion"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
  /v1/import-mappings/{id}/uses:
    get:
      operationId: listImportMappingUses
      tags:
        - import-mappings
      summary: The imports that used a template, newest first
      description: >
        One row per import that named the template (`?mapping_id=` on

        `POST /v1/debts/import` or `POST /v1/debt-lists/{id}/import`): the
        version

        it used and how many items went in and failed. Append-only. Capped at
        200.
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: The uses.
          content:
            application/json:
              schema:
                type: object
                required:
                  - uses
                properties:
                  uses:
                    type: array
                    items:
                      $ref: "#/components/schemas/ImportMappingUse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
  /v1/segment-filters:
    get:
      operationId: listSegmentFilters
      tags:
        - segment-filters
      summary: The tenant's saved segmentation filters, each with its current version
      description: >
        Behind the `debt_segmentation_enabled` flag: with it off (the default)
        every

        `/v1/segment-filters` route answers `404`, whatever the caller. By name,
        the

        live ones; `?include_archived=true` adds the archived. Each filter comes
        with

        its CURRENT version. Capped at 2 000.
      parameters:
        - name: include_archived
          in: query
          required: false
          schema:
            type: boolean
            default: false
      responses:
        "200":
          description: The filters.
          content:
            application/json:
              schema:
                type: object
                required:
                  - segment_filters
                properties:
                  segment_filters:
                    type: array
                    items:
                      $ref: "#/components/schemas/SegmentFilter"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
    post:
      operationId: createSegmentFilter
      tags:
        - segment-filters
      summary: Create a saved filter (version 1)
      description: >
        `name` is trimmed, not blank, at most 200 characters and unique among
        the

        tenant's LIVE filters (`409`). `criteria` goes through

        `Dialer.Debts.Segment` (ADR #221): a closed vocabulary, a value outside
        it

        is a `400`, and `phone`,

        `consumer_ref`, `external_ref` and `debtor_name` are refused by name.
        The

        server stores the canonical form (lists sorted, without repeats). An
        empty

        object is valid: the whole portfolio. A tenant holds at most 500 live

        filters (`422`). Needs the tenant scope (`403` for an agent token). With
        the

        flag off, `404`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SegmentFilterWrite"
            example:
              name: Texas student loans over 500 USD
              criteria:
                debt_type:
                  - student_loan
                us_state:
                  - TX
                min_amount_cents: 50000
      responses:
        "201":
          description: The filter, at version 1.
          content:
            application/json:
              schema:
                type: object
                required:
                  - segment_filter
                properties:
                  segment_filter:
                    $ref: "#/components/schemas/SegmentFilter"
        "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"
  /v1/segment-filters/{id}:
    get:
      operationId: getSegmentFilter
      tags:
        - segment-filters
      summary: One saved filter, at its current version
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: The filter (archived ones too, flagged `archived`).
          content:
            application/json:
              schema:
                type: object
                required:
                  - segment_filter
                properties:
                  segment_filter:
                    $ref: "#/components/schemas/SegmentFilter"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
    patch:
      operationId: updateSegmentFilter
      tags:
        - segment-filters
      summary: Rename a filter, or edit its criteria into a NEW version
      description: >
        Send at least one of `name`, `criteria`. A rename changes the header
        only.

        Changing `criteria` appends version n+1 and leaves version n exactly as
        it

        was: a list materialised from it may have used it. `criteria` REPLACES
        the

        current ones, it is not merged. An edit that changes nothing writes no

        version. `base_version` is optional and, when sent, must be the current

        version, otherwise `409` and nothing is written, so two people editing
        the

        same filter do not overwrite each other unnoticed. An archived filter
        cannot

        be edited (`409`); a filter holds at most 1 000 versions (`422`). Needs
        the

        tenant scope. With the flag off, `404`.
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                  maxLength: 200
                criteria:
                  $ref: "#/components/schemas/SegmentCriteria"
                base_version:
                  type: integer
                  minimum: 1
              additionalProperties: false
            example:
              base_version: 1
              criteria:
                us_state:
                  - TX
                  - NY
      responses:
        "200":
          description: The filter, at its current version after the edit.
          content:
            application/json:
              schema:
                type: object
                required:
                  - segment_filter
                properties:
                  segment_filter:
                    $ref: "#/components/schemas/SegmentFilter"
        "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: archiveSegmentFilter
      tags:
        - segment-filters
      summary: Archive a saved filter
      description: >
        Filters are never deleted: a version a list was materialised from is

        evidence. Archiving frees the name for a new filter and takes the filter
        out

        of the default list; it can no longer be edited. Idempotent. Needs the

        tenant scope. With the flag off, `404`.
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "204":
          description: The filter is archived.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
  /v1/segment-filters/{id}/to-list:
    post:
      operationId: materializeSegmentFilterToList
      tags:
        - segment-filters
      summary: Freeze what a saved filter selects NOW into a list
      description: >
        A PHOTO (ADR #221, core#1687): the debts the filter's LATEST version
        selects

        at this moment are put in the list, and the list does not follow the
        filter

        afterwards. Only the tenant's debts enter; the Reg F criteria use the
        real Reg F

        logic. Idempotent by `(list_id, debt_id)`: repeating the request adds
        nothing

        (`added: 0`) and still records its own evidence row.


        The target is EXACTLY ONE of `list_id` (an existing list of the tenant:
        `200`)

        or `new_list_name` (a list created in the same request, unique name:
        `201`);

        none or both is a `400`.


        The tenant's catalog cap `debt_segment_max_rows` (default 10 000, never
        above

        the Reg F candidate ceiling) bounds how many debts are written; reaching
        it is

        `truncated: true` in the answer, never silent, and the photo then covers
        only

        the most recent candidates. A cap of 0 or less is `422` and writes
        nothing.

        An archived filter, or an archived `list_id` list, is `409`. A `list_id`
        or filter that is not the tenant's,

        or not a UUID, is `404`. A request that fails leaves no empty list
        behind.

        Needs the tenant scope (`403` for an agent token). Behind the

        `debt_segmentation_enabled` flag: off, `404` and nothing is read or
        written.
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SegmentToListWrite"
            example:
              new_list_name: Texas student loans
              July photo: null
      responses:
        "200":
          description: The photo was written into the existing list.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SegmentMaterializationResult"
        "201":
          description: The list was created and the photo written into it.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SegmentMaterializationResult"
        "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"
  /v1/segment-filters/to-list:
    post:
      operationId: materializeSegmentCriteriaToList
      tags:
        - segment-filters
      summary: Freeze what inline criteria select NOW into a list
      description: >
        The same photo as `POST /v1/segment-filters/{id}/to-list`, with the
        criteria in

        the body instead of a saved filter (the evidence row then has no
        `filter_id`).

        `criteria` goes through `Dialer.Debts.Segment`: a value outside the
        closed

        vocabulary, a PII key or a non-object is a `400`, and nothing is
        written. An

        empty object is the whole portfolio, still bounded by the cap. Target,
        cap,

        scope and flag as in the saved-filter form.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SegmentToListInlineWrite"
            example:
              criteria:
                debt_type:
                  - student_loan
                us_state:
                  - TX
              list_id: 6f1c2a52-3b52-4b3d-9d57-0e5c6a8f1a10
      responses:
        "200":
          description: The photo was written into the existing list.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SegmentMaterializationResult"
        "201":
          description: The list was created and the photo written into it.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SegmentMaterializationResult"
        "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"
  /v1/qm/forms:
    get:
      operationId: listQmForms
      tags:
        - qm
      summary: The tenant's QM scoring forms, each with its current version
      description: >
        By name, the live ones; `?include_archived=true` adds the archived. Each

        form comes with its CURRENT version. Capped at 1 000. Reads work whether
        or

        not `qm_enabled` is on.
      parameters:
        - name: include_archived
          in: query
          required: false
          schema:
            type: boolean
            default: false
      responses:
        "200":
          description: The forms.
          content:
            application/json:
              schema:
                type: object
                required:
                  - forms
                properties:
                  forms:
                    type: array
                    items:
                      $ref: "#/components/schemas/QmForm"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
    post:
      operationId: createQmForm
      tags:
        - qm
      summary: Create a QM form (version 1)
      description: >
        A form is an ORDERED list of criteria plus the pass threshold (0 to
        100).

        `name` is trimmed, not blank, at most 200 characters and unique among
        the

        tenant's LIVE forms (`409`). A tenant holds at most 200 live forms
        (`422`).

        The body is checked as a whole: the first criterion that does not fit is

        named by its position in the `400` message. Unknown fields are refused.


        Writes need the `qm_enabled` flag (`403` `qm_not_enabled` while it is
        off)

        and a PERSON: a dashboard login with the `account_admin` role. A `dd_`
        API

        key, a `member` login or a widget token is `403` `role_forbidden`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/QmFormWrite"
            example:
              name: Cobranza — llamada saliente
              pass_threshold: 80
              criteria:
                - key: identificacion
                  label: Identifica a la empresa y al agente
                  kind: yes_no
                  weight: 2
                - key: mini_miranda
                  label: Lee el aviso legal completo
                  kind: yes_no
                  weight: 5
                  critical: true
                - key: empatia
                  label: Empatía con el deudor
                  kind: scale
                  max: 5
                  weight: 3
                  na_allowed: true
      responses:
        "201":
          description: The form, at version 1.
          content:
            application/json:
              schema:
                type: object
                required:
                  - form
                properties:
                  form:
                    $ref: "#/components/schemas/QmForm"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/QmWriteForbidden"
        "409":
          $ref: "#/components/responses/Conflict"
        "422":
          $ref: "#/components/responses/Unprocessable"
  /v1/qm/forms/{id}:
    get:
      operationId: getQmForm
      tags:
        - qm
      summary: One QM form, at its current version
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: The form (archived ones too, flagged `archived`).
          content:
            application/json:
              schema:
                type: object
                required:
                  - form
                properties:
                  form:
                    $ref: "#/components/schemas/QmForm"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
    patch:
      operationId: updateQmForm
      tags:
        - qm
      summary: Edit a QM form into a NEW version
      description: >
        Send at least one of `criteria`, `pass_threshold`; the one omitted is

        carried over from the current version. This appends version n+1 and
        leaves

        version n exactly as it was: an evaluation may have cited it. An edit
        that

        changes nothing writes no version and returns the current one.

        `base_version` is optional and, when sent, must be the current version —

        otherwise `409` and nothing is written, so two people editing the same
        form

        do not overwrite each other unnoticed. An archived form cannot be edited

        (`409`); a form holds at most 1 000 versions (`422`). Needs `qm_enabled`

        and an `account_admin` login.
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                criteria:
                  $ref: "#/components/schemas/QmCriteria"
                pass_threshold:
                  type: integer
                  minimum: 0
                  maximum: 100
                base_version:
                  type: integer
                  minimum: 1
              additionalProperties: false
            example:
              base_version: 1
              pass_threshold: 85
      responses:
        "200":
          description: The form, at its current version after the edit.
          content:
            application/json:
              schema:
                type: object
                required:
                  - form
                properties:
                  form:
                    $ref: "#/components/schemas/QmForm"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/QmWriteForbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "422":
          $ref: "#/components/responses/Unprocessable"
    delete:
      operationId: archiveQmForm
      tags:
        - qm
      summary: Archive a QM form
      description: >
        Forms are never deleted: an evaluation may have cited one of their
        versions.

        Archiving frees the name for a new form and takes the form out of the

        default list; it can no longer be edited. Idempotent. Needs `qm_enabled`

        and an `account_admin` login, like the other writes.
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "204":
          description: The form is archived.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/QmWriteForbidden"
        "404":
          $ref: "#/components/responses/NotFound"
  /v1/qm/forms/{id}/versions:
    get:
      operationId: listQmFormVersions
      tags:
        - qm
      summary: Every version of a QM form, oldest first
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: The versions (capped at 1 000).
          content:
            application/json:
              schema:
                type: object
                required:
                  - versions
                properties:
                  versions:
                    type: array
                    items:
                      $ref: "#/components/schemas/QmFormVersion"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
  /v1/qm/forms/{id}/versions/{version}:
    get:
      operationId: getQmFormVersion
      tags:
        - qm
      summary: One version of a QM form
      parameters:
        - $ref: "#/components/parameters/PathId"
        - name: version
          in: path
          required: true
          description: The version number (>= 1). Unknown, malformed or another tenant's
            reads as `404`.
          schema:
            type: integer
            minimum: 1
      responses:
        "200":
          description: The version, exactly as it was written.
          content:
            application/json:
              schema:
                type: object
                required:
                  - version
                properties:
                  version:
                    $ref: "#/components/schemas/QmFormVersion"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
  /v1/recordings/{id}/evaluations:
    get:
      operationId: listRecordingEvaluations
      tags:
        - qm
      summary: The QM evaluations of a recording, newest first
      description: >
        Every evaluation written for the recording, corrections included: each
        row

        says whether it is the `current` one (no other row replaces it) and
        which row

        it `supersedes_id`. Capped at 200. Reads work whether or not
        `qm_enabled` is

        on. A dashboard login that may not evaluate (an agent seat, a member) is

        `403 role_forbidden`: the rows say who evaluated. A `dd_` key reads. The

        `comment` is only present for an eligible evaluator (a supervisor or
        admin

        seat, or an `account_admin` login without a seat); for anyone else the
        key

        is absent.
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: The evaluations.
          content:
            application/json:
              schema:
                type: object
                required:
                  - evaluations
                properties:
                  evaluations:
                    type: array
                    items:
                      $ref: "#/components/schemas/QmEvaluation"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
    post:
      operationId: createRecordingEvaluation
      tags:
        - qm
      summary: Evaluate a recording with a QM form version
      description: >
        A supervisor scores ONE `available` recording against a version of a
        live form

        and the row is frozen: it is never edited. To correct it, send the same
        body

        with `supersedes_id` set to the CURRENT evaluation of that recording and
        form;

        that appends a new row and leaves the old one as it was. Without

        `supersedes_id` a recording holds one evaluation per form (`409` once
        there is

        one).


        The score is computed here, in basis points (8750 is 87.5 %), from the
        version

        you cite and your `answers`: `yes_no` takes `true` or `false`, `scale`
        an

        integer from 0 to `max`, and `"na"` where the criterion allows it. An
        answer is

        required for every criterion. `agent_id`, `call_attempt_id` and
        `campaign_id`

        come from the recording, never from the body. Unknown fields are
        refused.


        The checks run in this order and the first one that fails writes
        nothing:

        `qm_enabled` (`403` `qm_not_enabled`), the person (`403`
        `role_forbidden`),

        `400` (body shape), `404` (recording), `403` `self_evaluation_forbidden`

        (the recording is the evaluator's own), `409` (recording not
        `available`),

        `422` (no agent, AI leg, unknown form or version, answers that do not
        fit),

        `409` (archived form, or the chain). A `dd_` API key is a service, not a

        person: `403` `role_forbidden`.


        A correction is refused while the evaluation it corrects has an OPEN
        dispute (the

        agent opened it and nobody resolved it): `409` with `error.code`

        `evaluation_under_dispute`, nothing is written, and the evaluation keeps
        counting in

        the QM reports with its open dispute. That evaluation is corrected only
        by resolving

        the dispute as `adjusted`. Once the dispute is resolved `upheld`, a
        correction is

        accepted again.
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/QmEvaluationWrite"
            example:
              form_id: 5d3b0c8e-4f3a-4a0e-9d1c-0a5f9a9b7e11
              form_version: 1
              answers:
                identificacion: true
                mini_miranda: true
                empatia: 4
              comment: Buena apertura; falto cerrar con el aviso de grabacion.
      responses:
        "201":
          description: The evaluation, with its score and as the current one.
          content:
            application/json:
              schema:
                type: object
                required:
                  - evaluation
                properties:
                  evaluation:
                    $ref: "#/components/schemas/QmEvaluation"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/QmEvaluateForbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "422":
          $ref: "#/components/responses/Unprocessable"
  /v1/recordings/{id}/evaluations/{evaluation_id}:
    get:
      operationId: getRecordingEvaluation
      tags:
        - qm
      summary: One QM evaluation of a recording
      description: >
        The row exactly as it was written, plus whether it is still the
        `current` one.

        An id that belongs to another recording or another tenant reads as
        `404`.

        The `comment` and the `403` for a login that may not evaluate follow the
        same

        rule as in the list.
      parameters:
        - $ref: "#/components/parameters/PathId"
        - name: evaluation_id
          in: path
          required: true
          description: The evaluation UUID. Malformed, unknown or another tenant's reads
            as `404`.
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: The evaluation.
          content:
            application/json:
              schema:
                type: object
                required:
                  - evaluation
                properties:
                  evaluation:
                    $ref: "#/components/schemas/QmEvaluation"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
  /v1/qm/evaluations/{id}/dispute/resolution:
    post:
      operationId: resolveQmEvaluationDispute
      tags:
        - qm
      summary: Resolve the agent's dispute of an evaluation
      description: >
        An eligible evaluator (a supervisor or admin seat, or an `account_admin`
        login

        without a seat) closes the dispute that the evaluated agent opened. The
        `{id}` is

        the DISPUTED evaluation. `decision` is `upheld` (the evaluation stands)
        or

        `adjusted` (it is replaced). The evaluator cannot be the agent of the
        evaluation.

        A `dd_` key is a service, not a person: `403 role_forbidden`.


        `adjusted` takes `answers` (required, the same shape as when evaluating)
        and an

        optional `comment` for the new evaluation, which repeats the form and
        the version

        of the disputed one and is scored here from your answers. The new
        evaluation

        and the event that cites it (`adjusted_evaluation_id`) are written in
        ONE

        transaction: either both rows exist or neither. `upheld` takes neither
        `answers`

        nor `comment`. The new evaluation is read with

        `GET /v1/recordings/{id}/evaluations/{evaluation_id}`.


        The checks run in this order and the first one that fails writes
        nothing:

        `qm_enabled` (`403 qm_not_enabled`), the person (`403 role_forbidden`),
        `404`

        (an id that is not a UUID), `400` (the body shape), `404` (evaluation),

        `403 self_evaluation_forbidden`,

        `409` (the evaluation has no dispute, or it is already resolved), `422`
        (answers

        that do not fit the form version), `409` (the evaluation is no longer
        the

        current one).
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/QmDisputeResolve"
            example:
              decision: adjusted
              note: "Tiene razon: el aviso se dijo en el minuto 1:10."
              answers:
                identificacion: true
                mini_miranda: true
                empatia: 4
      responses:
        "201":
          description: The event the resolution appended to the dispute thread.
          content:
            application/json:
              schema:
                type: object
                required:
                  - dispute_event
                properties:
                  dispute_event:
                    $ref: "#/components/schemas/QmDisputeResolution"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/QmEvaluateForbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "422":
          $ref: "#/components/responses/Unprocessable"
  /v1/qm/reports/agents:
    get:
      operationId: getQmAgentReport
      tags:
        - qm
      summary: QM figures per agent and form over a window
      description: >
        Figures derived on every call from the CURRENT evaluations (a correction

        replaces the row it supersedes; the old one no longer counts) whose
        recording

        started in `[from, to)`. One row per agent, form AND `evaluator_kind`:
        two forms are

        two scales, and a person's score and the automatic pass are never
        averaged together.

        Per row: `evaluations`, `avg_score_bp`

        (mean score in basis points, rounded; `null` when there is nothing to
        average,

        never `0`), `pass_rate_bp` (`passed` over `evaluations` in basis points,
        `null`

        likewise), `passed` and `auto_failed` (voided by a critical criterion).
        No

        comment, phone number or other free text is ever returned.


        `from` and `to` are mandatory, `from` must be before `to` and the window
        may not

        exceed 31 days (`400` otherwise). Without `agent_id` only agents with
        data

        appear; with an existing one that has no evaluation in the window the
        answer is

        ONE row with `evaluations: 0` and the averages `null`; an unknown one,
        or

        another tenant's, is `404`. Reads work whether or not `qm_enabled` is
        on. A

        dashboard login that may not evaluate (an agent seat, a member) is

        `403 role_forbidden`; a `dd_` key reads.
      parameters:
        - name: from
          in: query
          required: true
          description: RFC 3339 lower bound on the recording start (inclusive).
          schema:
            type: string
            format: date-time
        - name: to
          in: query
          required: true
          description: RFC 3339 upper bound on the recording start (exclusive); at most 31
            days after `from`.
          schema:
            type: string
            format: date-time
        - name: agent_id
          in: query
          required: false
          description: Only this agent.
          schema:
            type: string
            format: uuid
        - name: form_id
          in: query
          required: false
          description: Only this form.
          schema:
            type: string
            format: uuid
        - name: evaluator_kind
          in: query
          required: false
          description: Only human, or only automatic, evaluations. Without it both appear,
            as separate rows.
          schema:
            type: string
            enum:
              - human
              - automatic
      responses:
        "200":
          description: The rows, ordered by agent, form then evaluator kind.
          content:
            application/json:
              schema:
                type: object
                required:
                  - from
                  - to
                  - rows
                properties:
                  from:
                    type: string
                    format: date-time
                  to:
                    type: string
                    format: date-time
                  rows:
                    type: array
                    items:
                      $ref: "#/components/schemas/QmAgentReportRow"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
  /v1/qm/reports/campaigns:
    get:
      operationId: getQmCampaignReport
      tags:
        - qm
      summary: QM figures per campaign and form over a window
      description: >
        Figures derived on every call from the CURRENT evaluations (a correction

        replaces the row it supersedes; the old one no longer counts) whose
        recording

        started in `[from, to)`. One row per campaign, form AND
        `evaluator_kind`: two forms are

        two scales, and a person's score and the automatic pass are never
        averaged together.

        Per row: `evaluations`, `avg_score_bp`

        (mean score in basis points, rounded; `null` when there is nothing to
        average,

        never `0`), `pass_rate_bp` (`passed` over `evaluations` in basis points,
        `null`

        likewise), `passed` and `auto_failed` (voided by a critical criterion).
        No

        comment, phone number or other free text is ever returned.


        `from` and `to` are mandatory, `from` must be before `to` and the window
        may not

        exceed 31 days (`400` otherwise). Without `campaign_id` only campaigns
        with data

        appear; with an existing one that has no evaluation in the window the
        answer is

        ONE row with `evaluations: 0` and the averages `null`; an unknown one,
        or

        another tenant's, is `404`. Reads work whether or not `qm_enabled` is
        on. A

        dashboard login that may not evaluate (an agent seat, a member) is

        `403 role_forbidden`; a `dd_` key reads.
      parameters:
        - name: from
          in: query
          required: true
          description: RFC 3339 lower bound on the recording start (inclusive).
          schema:
            type: string
            format: date-time
        - name: to
          in: query
          required: true
          description: RFC 3339 upper bound on the recording start (exclusive); at most 31
            days after `from`.
          schema:
            type: string
            format: date-time
        - name: campaign_id
          in: query
          required: false
          description: Only this campaign.
          schema:
            type: string
            format: uuid
        - name: form_id
          in: query
          required: false
          description: Only this form.
          schema:
            type: string
            format: uuid
        - name: evaluator_kind
          in: query
          required: false
          description: Only human, or only automatic, evaluations. Without it both appear,
            as separate rows.
          schema:
            type: string
            enum:
              - human
              - automatic
      responses:
        "200":
          description: The rows, ordered by campaign, form then evaluator kind.
          content:
            application/json:
              schema:
                type: object
                required:
                  - from
                  - to
                  - rows
                properties:
                  from:
                    type: string
                    format: date-time
                  to:
                    type: string
                    format: date-time
                  rows:
                    type: array
                    items:
                      $ref: "#/components/schemas/QmCampaignReportRow"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
  /v1/insights/anomalies:
    get:
      operationId: listInsightsAnomalies
      tags:
        - insights
      summary: Anomalies the evaluator opened, with their evidence
      description: >
        Every anomaly is its `opened` row: the observed value, the baseline it
        was measured

        against (`baseline_mean`, `baseline_stddev`, `stddev_floor`,
        `baseline_n`), the

        resulting `threshold` with its `k`, the bucket sample (`sample_n`) and
        the hourly

        `series` the dashboard draws. A `resolved` row that cites it makes
        `state` `resolved`

        and fills `resolution`. Newest first; page with `cursor`.


        The acknowledgement is PER PERSON: `read` is whether the calling user
        acknowledged

        the anomaly, and `unread=true` keeps only the ones the caller has not. A
        `dd_` key has

        no inbox: `read` is `null` and `unread=true` is `400`. Always on: there
        is no

        activation flag.
      parameters:
        - name: campaign_id
          in: query
          required: false
          description: Only this campaign's series.
          schema:
            type: string
            format: uuid
        - name: metric
          in: query
          required: false
          schema:
            $ref: "#/components/schemas/InsightsMetric"
        - name: state
          in: query
          required: false
          schema:
            type: string
            enum:
              - open
              - resolved
        - name: unread
          in: query
          required: false
          description: "`true` keeps the anomalies the calling user has not acknowledged
            (a dashboard login only)."
          schema:
            type: boolean
        - name: limit
          in: query
          required: false
          description: Page size, 50 by default and at most 200.
          schema:
            type: integer
            minimum: 1
            maximum: 200
            default: 50
        - name: cursor
          in: query
          required: false
          description: The `next_cursor` of the previous page.
          schema:
            type: string
      responses:
        "200":
          description: A page of anomalies.
          content:
            application/json:
              schema:
                type: object
                required:
                  - anomalies
                  - next_cursor
                properties:
                  anomalies:
                    type: array
                    items:
                      $ref: "#/components/schemas/InsightsAnomaly"
                  next_cursor:
                    type:
                      - string
                      - "null"
                    description: "`null` on the last page."
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
  /v1/insights/series:
    get:
      operationId: getInsightsSeries
      tags:
        - insights
      summary: One metric as hourly buckets with the band the rule uses
      description: >
        The hourly buckets of `metric` in `[from, to)` and, for each, the band

        `mean ± k·max(σ, floor)` that `zscore.v1` computes from the 7 days
        before the bucket,

        the bucket itself excluded: the same arithmetic as the evaluator, so the
        chart shows

        why a point did or did not cross. `band` is `null` while the bucket has
        fewer than 24

        baseline buckets (`min_baseline`); a bucket under `min_sample` attempts
        is drawn but

        never evaluated. `k` is the tenant's `insights_anomaly_k`.


        `from` and `to` round down to the hour; `to` defaults to now and `from`
        to 48 hours

        before it. The window may not exceed 14 days. `gate` is mandatory for

        `gate_block_rate` and is refused for every other metric.
      parameters:
        - name: metric
          in: query
          required: true
          schema:
            $ref: "#/components/schemas/InsightsMetric"
        - name: campaign_id
          in: query
          required: false
          description: The series of this campaign; absent, the whole tenant.
          schema:
            type: string
            format: uuid
        - name: gate
          in: query
          required: false
          description: The compliance gate (1 to 64 bytes); only with `gate_block_rate`.
          schema:
            type: string
            maxLength: 64
        - name: from
          in: query
          required: false
          description: RFC 3339 lower bound (inclusive), rounded down to the hour.
          schema:
            type: string
            format: date-time
        - name: to
          in: query
          required: false
          description: RFC 3339 upper bound (exclusive), rounded down to the hour.
          schema:
            type: string
            format: date-time
      responses:
        "200":
          description: The series.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/InsightsSeries"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
  /v1/insights/anomalies/{id}/ack:
    post:
      operationId: acknowledgeInsightsAnomaly
      tags:
        - insights
      summary: Acknowledge an anomaly as the calling user
      description: >
        Writes the calling user's acknowledgement (append-only, one per user and
        anomaly).

        Idempotent: acknowledging again answers `200` with the acknowledgement
        that already

        exists. Needs a dashboard login: a `dd_` key has no inbox and is `403`.
        An id that is

        not an anomaly of the tenant (another tenant's, a resolution row, an
        invented one) is

        `404`.
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: The acknowledgement.
          content:
            application/json:
              schema:
                type: object
                required:
                  - ack
                properties:
                  ack:
                    $ref: "#/components/schemas/InsightsAck"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
  /v1/product-analytics/events:
    post:
      operationId: recordProductAnalyticsEvents
      tags:
        - product-analytics
      summary: Record the calling user's console usage events
      description: >
        The console reports which screen a user opened and which function they
        used. Each

        event is a KEY of a closed catalog (`screen.<route>` or
        `feature.<function>`): there

        is no free-text field, so no data of a consumer can travel in one. The
        server stamps

        the time and the tenant (the login's, never body input). Keys outside
        the catalog are

        dropped and counted in `rejected`, so a console newer than the server
        breaks nothing.

        At most 50 keys per request. Events are kept 180 days.


        Needs a dashboard login: a `dd_` key has no person behind it and is
        `403`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - events
              properties:
                events:
                  type: array
                  maxItems: 50
                  items:
                    type: string
                    example: screen.campaigns
      responses:
        "202":
          description: Accepted; how many keys were stored and how many were dropped.
          content:
            application/json:
              schema:
                type: object
                required:
                  - accepted
                  - rejected
                properties:
                  accepted:
                    type: integer
                    minimum: 0
                  rejected:
                    type: integer
                    minimum: 0
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
  /v1/product-analytics/nps:
    get:
      operationId: getProductAnalyticsNpsPrompt
      tags:
        - product-analytics
      summary: Is the NPS question due for the calling user?
      description: >
        `due` is true only for an `account_admin` whose last NPS record (an
        answer or a

        dismissal) is at least `interval_days` old, or who has none. Any other
        user gets

        `due: false`. Needs a dashboard login.
      responses:
        "200":
          description: Whether to ask now.
          content:
            application/json:
              schema:
                type: object
                required:
                  - due
                  - interval_days
                properties:
                  due:
                    type: boolean
                  interval_days:
                    type: integer
                    example: 90
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
    post:
      operationId: submitProductAnalyticsNps
      tags:
        - product-analytics
      summary: Answer the NPS question, or dismiss it
      description: >
        Send `{ "score": 0..10, "comment": "..." }` (the comment is optional, at
        most 500

        characters) or `{ "dismissed": true }`. Both postpone the next question
        by

        `interval_days`. Only an `account_admin` answers (`403` otherwise);
        asking again before

        the interval is `409`. The comment is free text a person types: the
        console asks them

        not to put customer data in it.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                score:
                  type: integer
                  minimum: 0
                  maximum: 10
                comment:
                  type: string
                  maxLength: 500
                dismissed:
                  type: boolean
      responses:
        "201":
          description: The stored record.
          content:
            application/json:
              schema:
                type: object
                required:
                  - response
                properties:
                  response:
                    type: object
                    required:
                      - outcome
                      - score
                      - comment
                    properties:
                      outcome:
                        type: string
                        enum:
                          - answered
                          - dismissed
                      score:
                        type:
                          - integer
                          - "null"
                      comment:
                        type:
                          - string
                          - "null"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "409":
          $ref: "#/components/responses/Conflict"
  /v1/disclosure-scripts:
    get:
      operationId: listDisclosureScripts
      tags:
        - disclosure-scripts
      summary: The tenant's collection-notice script versions, with their status
      description: >
        Every version of every selector, ordered by `jurisdiction`, `debt_type`
        and

        then the newest version first. The `status` comes from the sealed
        events,

        never from the version row: a version with no `approved` event is a
        `draft`

        and never reads as approved. `jurisdiction`, `debt_type` and `status`
        filter.

        Readable by any tenant-scope token (a `dd_` key or a dashboard login).
      parameters:
        - name: jurisdiction
          in: query
          required: false
          schema:
            type: string
        - name: debt_type
          in: query
          required: false
          schema:
            type: string
        - name: status
          in: query
          required: false
          schema:
            type: string
            enum:
              - draft
              - approved
              - retired
      responses:
        "200":
          description: The versions.
          content:
            application/json:
              schema:
                type: object
                required:
                  - scripts
                properties:
                  scripts:
                    type: array
                    items:
                      $ref: "#/components/schemas/DisclosureScript"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
    post:
      operationId: createDisclosureScript
      tags:
        - disclosure-scripts
      summary: Write a DRAFT version of a collection-notice script
      description: >
        Creates an immutable draft for the selector `(jurisdiction, debt_type)`;

        the version number is assigned by core (`max + 1` per tenant and
        selector).

        A draft is NOT an approved script: it needs `POST .../approve`. `text`
        is at

        most 10 000 bytes; `text_sha256` is derived by core from the text and is

        never accepted from the client. `legal_ref` (the citation of the rule or

        opinion the text rests on) is required and not blank. `jurisdiction` is
        a

        USPS state code or `*`; `debt_type` is `other`, `student_loan` or `*`.

        Unknown fields are `400`. The platform does not judge the legal content
        of

        the text. Needs a `dd_` key or a dashboard login with the
        `account_admin`

        role.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/DisclosureScriptWrite"
            example:
              jurisdiction: "*"
              debt_type: "*"
              text: Texto del aviso redactado y revisado por el despacho del tenant.
              legal_ref: Dictamen interno 2026-10 del despacho
      responses:
        "201":
          description: The draft.
          content:
            application/json:
              schema:
                type: object
                required:
                  - script
                properties:
                  script:
                    $ref: "#/components/schemas/DisclosureScript"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/DisclosureScriptWriteForbidden"
  /v1/disclosure-scripts/{id}/approve:
    post:
      operationId: approveDisclosureScript
      tags:
        - disclosure-scripts
      summary: Approve a draft version (sealed in the compliance chain)
      description: >
        Writes an append-only `approved` event and seals it in the tenant's

        compliance chain in the SAME transaction: if the seal fails nothing is

        written and the version stays a draft. `reviewed_by` (the person or firm

        that reviewed the text) is required. A version that is already approved
        or

        retired is `409`. Another tenant's version, or a non-UUID id, is `404`.

        Needs a `dd_` key or an `account_admin` login.
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - reviewed_by
              properties:
                reviewed_by:
                  type: string
              additionalProperties: false
            example:
              reviewed_by: Despacho de ejemplo
      responses:
        "200":
          description: The version, now `approved`.
          content:
            application/json:
              schema:
                type: object
                required:
                  - script
                properties:
                  script:
                    $ref: "#/components/schemas/DisclosureScript"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/DisclosureScriptWriteForbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
  /v1/disclosure-scripts/{id}/retire:
    post:
      operationId: retireDisclosureScript
      tags:
        - disclosure-scripts
      summary: Retire an approved version (sealed in the compliance chain)
      description: >
        Writes an append-only `retired` event and seals it, like approval. Only
        an

        approved version is retired: a draft is `409` (it was never approved)
        and so

        is one already retired. `reviewed_by` is optional here. Another tenant's

        version, or a non-UUID id, is `404`. Needs a `dd_` key or an
        `account_admin`

        login.
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                reviewed_by:
                  type: string
              additionalProperties: false
      responses:
        "200":
          description: The version, now `retired`.
          content:
            application/json:
              schema:
                type: object
                required:
                  - script
                properties:
                  script:
                    $ref: "#/components/schemas/DisclosureScript"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/DisclosureScriptWriteForbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
  /v1/debt-lists:
    get:
      operationId: listDebtLists
      tags:
        - debt-lists
      summary: The tenant's reusable lists, with their size counted on read
      description: >
        Every list of the tenant, by name. `size` (members) and `campaign_ids`

        (the campaigns the list is associated with) are counted when the request

        is served: the platform keeps no counter for them, so the number is
        never

        older than the response. Capped at 2 000 lists. Archived lists

        (`POST /v1/debt-lists/{id}/archive`) are excluded unless

        `include_archived=true`.
      parameters:
        - name: include_archived
          in: query
          required: false
          description: "`true` also returns the archived lists. Anything else leaves them
            out."
          schema:
            type: boolean
            default: false
      responses:
        "200":
          description: The lists.
          content:
            application/json:
              schema:
                type: object
                required:
                  - debt_lists
                  - truncated
                properties:
                  debt_lists:
                    type: array
                    items:
                      $ref: "#/components/schemas/DebtList"
                  truncated:
                    type: boolean
                    description: >
                      `true` when there are more rows than the 2 000 lists the
                      listing returns: the response is a

                      prefix in the documented order, not the whole set.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
    post:
      operationId: createDebtList
      tags:
        - debt-lists
      summary: Create a reusable list
      description: >
        A list is a named set of the tenant's debts. It is worked by

        ASSOCIATING it with campaigns (`PUT
        /v1/debt-lists/{id}/campaigns/{campaign_id}`),

        never by copying a debt: one debt is one account wherever it is worked.

        `name` is trimmed, must not be blank (`400`), is at most 200 characters,

        and is unique in the tenant (`409`).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - name
              properties:
                name:
                  type: string
                  maxLength: 200
              additionalProperties: false
            example:
              name: Cartera Q3
      responses:
        "201":
          description: The new list (size 0, no campaigns).
          content:
            application/json:
              schema:
                type: object
                required:
                  - debt_list
                properties:
                  debt_list:
                    $ref: "#/components/schemas/DebtList"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "409":
          $ref: "#/components/responses/Conflict"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/debt-lists/{id}:
    get:
      operationId: getDebtList
      tags:
        - debt-lists
      summary: One reusable list
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: The list, with its size and campaigns counted on read.
          content:
            application/json:
              schema:
                type: object
                required:
                  - debt_list
                properties:
                  debt_list:
                    $ref: "#/components/schemas/DebtList"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/debt-lists/{id}/archive:
    post:
      operationId: archiveDebtList
      tags:
        - debt-lists
      summary: Archive a list (terminal, never a delete)
      description: >
        A list is evidence of whom a campaign worked, so it is never deleted: it

        is archived (`archived_at`, terminal, append-only). `GET /v1/debt-lists`

        stops returning it unless `include_archived=true`; `GET
        /v1/debt-lists/{id}`

        still reads it, with `archived: true`. Its members and its past

        associations stay. An archived list admits no new members (list import,

        segment snapshot) and cannot be associated with a campaign: `409`.

        IDEMPOTENT: archiving an archived list answers it unchanged. `409` while

        the list is associated with a campaign that is not `archived`

        (fail-closed: dissociate it or archive the campaign first). `404` when

        the list is not the tenant's. No body.
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: The list, now archived.
          content:
            application/json:
              schema:
                type: object
                required:
                  - debt_list
                properties:
                  debt_list:
                    $ref: "#/components/schemas/DebtList"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/debt-lists/{id}/import:
    post:
      operationId: importDebtsToList
      tags:
        - debt-lists
      summary: Bulk import INTO a list (the pipeline of /v1/debts/import)
      description: >
        The SAME body, caps, per-item fail-soft answer and item rules as

        `POST /v1/debts/import` — debts upsert on `external_ref`, contacts on

        `phone_e164`, the contact fields are additive and tri-state, a `null`

        fails the item — plus two rules of its own:


        * each debt that imports enters the list, as the LAST step of its item:
          an item that fails at any step is not added (its debt may already be
          written, exactly as on `/v1/debts/import`, but it enters no campaign
          through this list). Adding is idempotent: replaying the payload adds
          nothing twice. No import ever takes a debt out of a list or a campaign;
          `DELETE /v1/debt-lists/{id}/members/{debt_id}` is the verb for that.
        * an item carrying `campaign_id` — any value, `null` included — FAILS
          that item, before anything is written for it. The campaigns of a
          list's debts are the list's associations; the direct `campaign_id` of
          a debt is set on `/v1/debts/import`.

        Every member of the list — imported now or later — is in the population

        of every campaign the list is associated with. `404` when the list is
        not

        the tenant's: nothing is written, not even the debts.
      parameters:
        - $ref: "#/components/parameters/PathId"
        - $ref: "#/components/parameters/ImportMappingIdQuery"
        - $ref: "#/components/parameters/ImportMappingVersionQuery"
        - $ref: "#/components/parameters/ImportMappingImportIdQuery"
      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-0002
                  consumer_ref: cms-consumer-9002
                  amount_cents: 48000
                  currency: USD
                  contacts:
                    - phone_e164: "+13125551002"
                      line_type: landline
                      timezone: America/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
                  mapping:
                    type: object
                    description: "Present only when the request named `mapping_id`: the template
                      version recorded as used."
                    required:
                      - id
                      - version
                    properties:
                      id:
                        type: string
                        format: uuid
                      version:
                        type: integer
              example:
                imported: 1
                failed: []
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "429":
          $ref: "#/components/responses/ImportThrottled"
  /v1/debt-lists/{id}/members:
    get:
      operationId: listDebtListMembers
      tags:
        - debt-lists
      summary: Read the members of a list
      description: >
        The debts that are in the list, oldest entry first, paginated by an
        opaque

        keyset cursor: a page never repeats or loses a member. Each row carries

        `debt_id`, the client's `external_ref` and `added_at` — no phone and no

        debtor name. An archived list is still readable. `404` when the list is

        not the tenant's. Which import put a debt in is not recorded per member:

        an import is undone by reading its `external_ref`s and removing them
        with

        `DELETE /v1/debt-lists/{id}/members`.
      parameters:
        - $ref: "#/components/parameters/PathId"
        - name: limit
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 500
            default: 500
          description: Page size (a value over the ceiling is capped, not refused).
        - 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 members.
          content:
            application/json:
              schema:
                type: object
                required:
                  - members
                  - next_cursor
                properties:
                  members:
                    type: array
                    items:
                      $ref: "#/components/schemas/DebtListMember"
                  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"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
    delete:
      operationId: removeDebtListMembers
      tags:
        - debt-lists
      summary: Take up to 500 debts out of a list in one call
      description: >
        The batch twin of `DELETE /v1/debt-lists/{id}/members/{debt_id}`, with
        the

        same guarantee: the debts are untouched, they only leave the list and so

        the population of the campaigns that had them through it (unless they
        are

        in them another way: their own `campaign_id`, or another list).

        IDEMPOTENT: an id that is not a member removes nothing and is not an
        error;

        the answer counts the ones that LEFT. All-or-nothing on shape: `400`
        when

        `debt_ids` is empty, has more than 500 or carries a value that is not a

        UUID, and then nothing leaves. `404` only when the list is not the

        tenant's. The body travels on a `DELETE`.
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - debt_ids
              properties:
                debt_ids:
                  type: array
                  minItems: 1
                  maxItems: 500
                  items:
                    type: string
                    format: uuid
            example:
              debt_ids:
                - 6f1d3b0e-4c1a-4a52-9a43-0a3f6c1d9e11
      responses:
        "200":
          description: How many debts left the list.
          content:
            application/json:
              schema:
                type: object
                required:
                  - removed
                properties:
                  removed:
                    type: integer
                    minimum: 0
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/debt-lists/{id}/members/{debt_id}:
    delete:
      operationId: removeDebtListMember
      tags:
        - debt-lists
      summary: Take one debt out of a list
      description: >
        The debt leaves the list, and with it the population of the campaigns
        the

        list is associated with (unless it is in them another way: its own

        `campaign_id`, or another list). The debt itself is untouched. `404`
        when

        it was not a member of the tenant's list.
      parameters:
        - $ref: "#/components/parameters/PathId"
        - name: debt_id
          in: path
          required: true
          description: The member debt's UUID. Malformed or cross-tenant ids read as `404`.
          schema:
            type: string
            format: uuid
      responses:
        "204":
          description: The debt is no longer a member.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/debt-lists/{id}/campaigns/{campaign_id}:
    put:
      operationId: associateDebtList
      tags:
        - debt-lists
      summary: Associate a list with a campaign
      description: >
        Every member of the list — the ones there now and the ones imported
        later

        — enters the campaign's population from its next read (the next tick of

        its runner). A debt can therefore be in several campaigns at once: each

        works it with its own marks, while the Reg F 7-in-7 counter, the park of
        a

        disposition rule and the debt's state stay per debt and cross campaigns,

        so a second campaign never adds calls beyond the cap. IDEMPOTENT: a
        second

        `PUT` changes nothing and answers the same. `404` when the list or the

        campaign is not the tenant's; `409` when the list is archived. No body.
      parameters:
        - $ref: "#/components/parameters/PathId"
        - $ref: "#/components/parameters/DebtListCampaignId"
      responses:
        "200":
          description: The list, with its campaigns.
          content:
            application/json:
              schema:
                type: object
                required:
                  - debt_list
                properties:
                  debt_list:
                    $ref: "#/components/schemas/DebtList"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
    delete:
      operationId: dissociateDebtList
      tags:
        - debt-lists
      summary: Dissociate a list from a campaign
      description: |
        The list's members leave the campaign's population from its next read
        (unless they are in it another way). The debts, their own `campaign_id`
        and the list are untouched. `404` when there was no such association in
        the tenant.
      parameters:
        - $ref: "#/components/parameters/PathId"
        - $ref: "#/components/parameters/DebtListCampaignId"
      responses:
        "204":
          description: The association is gone.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /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.

        `disclosure.script` (core#1690, ADR #219 Decision 4) is the approved
        collection-notice

        script that applies to the call, or `null`; the station reads it and
        computes nothing.
      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"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /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.


        WHO the outcome — and the promise born with it — is attributed to
        depends

        on the principal (ADR #133, ADR #134). A `dd_` machine key may name any

        agent of its tenant. A `ddu_` login session whose person is linked to an

        agent seat authors as THAT seat: `agent_id` may be omitted (filled with

        the seat) or repeat it; naming another agent is `403 forbidden` and

        nothing is appended — no correction, no promise. A `ddw_` may only name

        its own agent. A `ddu_` without a linked seat may name another agent

        only with `users.role = account_admin` — a `member` is `403

        role_forbidden`, nothing appended — once the tenant's catalog key

        `ddu_author_role_enforced` is on (born off: until then the seatless

        session keeps the `dd_` contract).


        `disclosure` (optional) is the agent's attestation about the mandatory

        collection notice, written in the SAME transaction as the correction
        (see

        `POST /v1/call_attempts/{id}/disclosure` for what it states and what it
        does

        not). It is strict like `promise`: a key outside the list, a malformed

        `supersedes_id`, a `not_delivered` without `reason`, or no `outcome` is
        a

        `400` and NOTHING is appended — no correction, no attestation. It takes
        the

        author of the disposition, so the object does not repeat `agent_id`; a

        request that attests with no agent named is `400`. Without the object
        the

        response has no `disclosure` key and the closing answers exactly what it

        always did.


        ENFORCEMENT, behind the tenant's catalog key
        `disclosure_attestation_enforced`

        (born OFF: until it is on the closing answers exactly what it always did
        and

        a contacted call with no attestation reads `unattested`). With it ON, a
        call

        IN SCOPE — one with an effective debt whose pipeline row is
        `answered_human`

        or `transferred`, decided from the call's facts and never from the code
        the

        agent types — that has no attestation (earlier, or in this body's

        `disclosure`) is `422` with `error.code:
        disclosure_attestation_required`,

        and NOTHING is written: no correction, no promise, no attestation. A
        call

        whose pipeline row does not exist yet cannot be decided and is `409`
        with

        `error.code: attempt_not_final`: retry once the call ends. A call with
        no

        debt is not in scope and closes as before. Turning the key OFF only
        lifts the

        block; no attestation is ever created by the platform.


        `suggestion_id` (optional) cites the AI suggestion the agent saw

        (`GET /v1/call_attempts/{id}/analysis`). It ONLY records that the
        suggestion

        was shown and what the agent typed; the outcome is always the
        `disposition`

        in this body, never the suggested one, and the suggestion changes
        nothing

        about the retry policy. A malformed id is `400`; an id that is not this

        attempt's analysis is `404` and NOTHING is appended. The same suggestion
        cited

        twice records one adoption.


        PLATFORM EFFECT (core#2551, ADR #337): four codes carry a consequence
        that is the

        platform's and not a tenant rule, written in the SAME transaction as the
        correction

        (the outcome and its effect commit together or neither does).
        `cease_and_desist`,

        `attorney_represented` and `deceased` write a cease-and-desist for the
        consumer

        (`consumer_ref` of the attempt, or of its debt) and list the number in
        the tenant's

        DNC (source `agent_cease_and_desist`); from then on every voice and SMS
        to that

        consumer is blocked by the consent gate (`fdcpa.cease_and_desist.v1`),
        whatever the

        debt. `attorney_represented` and `deceased` are blocked by default while
        counsel

        decides. `do_not_call` lists the number in the tenant's DNC (source

        `agent_dnc_request`) and does not cease the consumer's other numbers.
        The number is

        the one dialed on an outbound attempt and the caller's on an inbound
        one. Each write

        leaves its event in the sealed opt-out history; repeating the request
        writes

        nothing twice. The tenant's rules (below) run on top and cannot relax
        the effect.

        If the effect cannot be written the whole request fails and NOTHING is
        appended.


        Once recorded, the disposition is also handed to the tenant's retry

        policy (`/v1/disposition-rules`), AFTER the outcome committed: a policy

        failure never undoes the outcome, and the response does not report the

        policy's effects. The retry is measured from the ATTEMPT (the instant it

        closed), not from when this request arrives, so repeating the request

        writes the same instant. The policy can only postpone or close the debt:

        it never brings a parked debt forward, never touches a debt outside the

        dialable states, and does not reschedule a debt with a pending callback.

        Every dial is still gated by Reg F at originate time. A tenant with no

        rule for the code sees no change.
      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
                  description: The disposing agent. Optional; for a `ddu_` session linked to a
                    seat it must be that seat (or be omitted).
                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`.
                  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, at most the tenant's `promise_max_horizon_days` out
                        (90 by default).
                    note:
                      type: string
                disclosure:
                  oneOf:
                    - $ref: "#/components/schemas/DisclosureAttestation"
                    - type: "null"
                  description: The agent's attestation about the collection notice for this call,
                    written with the correction in one transaction. Omit it (or
                    send `null`) to write none.
                suggestion_id:
                  oneOf:
                    - type: string
                      format: uuid
                    - type: "null"
                  description: The `id` of the AI analysis whose suggestion the agent saw. Only
                    records the adoption; it never chooses or changes the
                    outcome.
            examples:
              simple:
                value:
                  disposition: wrong_number
              citing_the_ai_suggestion:
                value:
                  disposition: promise_to_pay
                  suggestion_id: 7c9e6679-7425-40de-944b-e07fc1f90ae7
                  promise:
                    amount_cents: 12500
                    currency: USD
                    promised_date: 2026-07-21
              with_disclosure:
                value:
                  disposition: answered_human
                  agent_id: 1b2c3d4e-5f6a-4b7c-8d9e-0f1a2b3c4d5e
                  disclosure:
                    outcome: delivered
              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, + the
            attestation when one was sent).
          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"
                  disclosure:
                    $ref: "#/components/schemas/DisclosureEvent"
        "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/DispositionThrottled"
  /v1/call_attempts/{id}/analysis:
    get:
      operationId: getCallAttemptAnalysis
      tags:
        - call-attempts
      summary: AI summary and suggested disposition of a call
      description: >
        The summary of ONE finished call and the disposition the AI SUGGESTS. It
        is a

        suggestion, never an outcome: nothing is applied, and the agent confirms
        by

        typing the disposition (`POST /v1/call_attempts/{id}/disposition`,
        optionally

        citing `suggestion_id` = this `id`).


        `suggested_code` is always a `code` ACTIVE in the tenant's own catalog,
        or

        `null` with the reason in `suggestion_note`. `suggested_class` is that
        code's

        class today; a suggestion of class `do_not_call` is returned as such and
        a

        client must not preselect it.


        Readable by the agent that owns the attempt (a `ddu_` login whose person
        is linked

        to the seat, or the `ddw_` token bound to that agent) and by a
        supervisor or

        admin. A `dd_` key is a service and is `403 role_forbidden`, as is a
        login with no

        seat and no supervisor role. `404` when the attempt has no analysis in
        this tenant:

        it belongs to another agent or another tenant, it was never analysed
        (not

        eligible, no entitlement, or still pending), its audio was purged (the
        purge deletes

        the analysis with it), or the id is malformed. A client treats `404` as
        "no

        suggestion yet" and keeps the manual typing.
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: The analysis.
          content:
            application/json:
              schema:
                type: object
                required:
                  - analysis
                properties:
                  analysis:
                    $ref: "#/components/schemas/CallAnalysis"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
  /v1/call_attempts/{id}/disclosure:
    post:
      operationId: recordDisclosure
      tags:
        - call-attempts
      summary: Agent's attestation of the collection notice
      description: >
        Records, append-only, what the agent DECLARED about the mandatory
        collection

        notice on a call: that it was `delivered`, or `not_delivered` with a
        closed

        `reason`. It does NOT prove the notice was said or heard —
        `evidence_kind` is

        `agent_attestation` and says so on the wire. The row is sealed in the

        tenant's compliance chain in the same transaction: if the seal fails
        nothing

        is written. `{id}` must be an ORIGINAL attempt (`corrects_id: null`); a

        correction, an unknown id or another tenant's attempt is `404`. A call
        with no

        debt (not even from an assignment) has nothing to attest against: `422`.


        There is NO default: a body without `outcome` is `400`. An unknown key
        in the

        body is `400`. `reason` is required with `not_delivered` and refused
        with

        `delivered`; `note` is accepted only with `reason: other` and is stored

        encrypted. `script_sha256` (lowercase hex sha256 of the notice text the
        agent

        read) is optional in v1.


        `script_version_id` (optional, core#1690, ADR #219) links the
        attestation to the

        version of the tenant's script library the agent read. core checks that
        it is a

        version of THIS tenant, that it was approved and not yet retired when
        the call

        started (and not approved after the call ended), and that its selector
        was a

        candidate for the call's state and debt type (the facts frozen in the
        attempt's

        `compliance_snapshot`, never today's contact record); then it derives
        the

        `script_sha256` from the version and the `script_resolution` (the
        precedence

        branch the version's selector belongs to: `exact`, `state`, `debt_type`
        or

        `tenant_default`) and seals both with the attestation. A version
        replaced or

        retired AFTER the call started is still valid: what is attested is what
        the

        agent saw, not what would resolve today. A `script_version_id` that is
        not a

        UUID, is another tenant's, a draft, does not qualify for this call, or
        comes

        with a `script_sha256` that is not its digest is `422` with

        `error.code: disclosure_script_mismatch`, and NOTHING is written.
        `script_resolution`

        is never accepted from the caller (`400`). Without `script_version_id`
        the

        attestation is exactly what it always was, and the response has no

        `script_version_id` or `script_resolution` key. With the tenant's
        `disclosure_script_enforced` flag on and at

        least one approved version, a call in scope must name its script:
        without

        `script_version_id` it is `422` with `error.code:
        disclosure_script_required`; when

        no approved script resolves for the call (`none` or `undecidable`) it is
        `409` with

        `error.code: disclosure_script_unresolvable`; nothing is written in
        either case. The same field is accepted inside

        the `disclosure` object of `POST /v1/call_attempts/{id}/disposition`,
        where a

        mismatch also leaves the correction unwritten.


        One «first» attestation per call: sending a second one without
        `supersedes_id`

        is `409`. To rectify, send a new attestation whose `supersedes_id` is
        the

        CURRENT one of the same call; nothing is edited or deleted. Rectifying
        one

        that is not the current one is `409`; a malformed `supersedes_id` is
        `400`.


        WHO attests follows the same rule as `POST
        /v1/call_attempts/{id}/disposition`:

        a `dd_` machine key must name the agent in `agent_id` (none → `400`

        `bad_request`, nobody to attribute it to); a `ddu_` session linked to an
        agent

        seat signs as THAT seat, and naming another agent is `403`; a `ddw_`
        signs only

        as its own agent and only a call of its own (`403` otherwise), and
        nothing is

        written. Shares the disposition rate brake (`429`).
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/DisclosureAttestation"
                - type: object
                  properties:
                    agent_id:
                      type: string
                      format: uuid
                      description: The attesting agent. Required for a `dd_` key; for a `ddu_` linked
                        to a seat it is that seat (or omitted).
            examples:
              delivered:
                value:
                  agent_id: 1b2c3d4e-5f6a-4b7c-8d9e-0f1a2b3c4d5e
                  outcome: delivered
              not_delivered:
                value:
                  agent_id: 1b2c3d4e-5f6a-4b7c-8d9e-0f1a2b3c4d5e
                  outcome: not_delivered
                  reason: consumer_hung_up
      responses:
        "201":
          description: The sealed attestation.
          content:
            application/json:
              schema:
                type: object
                required:
                  - disclosure
                properties:
                  disclosure:
                    $ref: "#/components/schemas/DisclosureEvent"
        "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/DispositionThrottled"
  /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 the tenant's minimum
        notice (`callback_min_notice_seconds`, 10 minutes by default) → `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 ONE campaign whose population holds the

        debt — its own `campaign_id`, or the campaign of a list it is in. A debt

        in MORE than one campaign (its own and a list's, or two lists') has no

        default: omitting `campaign_id`, or sending it `null`, is `422` — the

        platform does not pick a campaign for the caller. The campaign runner

        dials due callbacks BEFORE fresh leads.


        `agent_id` is the callback's CREATOR and is optional (a callback may

        have no agent).

        **Who may name whom** (ADR #133, ADR #134). The `agent_id` is bound by

        the PRINCIPAL, the same rule on every verb that reads one: a `dd_`

        machine key names any agent of its tenant; a `ddw_` only its own; a

        `ddu_` login session linked to an agent seat acts as THAT seat —

        `agent_id` may be omitted (filled with the seat) or repeat it, and

        naming another agent is `403 forbidden`; a `ddu_` session with NO

        linked seat may name another agent only with `users.role =

        account_admin` — a `member` is `403 role_forbidden` — once the tenant's

        catalog key `ddu_author_role_enforced` is on (born off: until then the

        seatless session keeps the `dd_` contract on this verb).
      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 the tenant's `callback_min_notice_seconds` (10
                    minutes by default) 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"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
    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; `done`,
        `cancelled`

        and `missed` list the most recent appointment first). `agent_id` scopes
        to the

        callbacks that agent created. The queue view pages by keyset: pass the
        previous

        `next_cursor` as `cursor` (opaque; `null` on the last page). `due=now`
        is not paged.
      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/AgentIdFilter"
        - $ref: "#/components/parameters/LimitParam"
        - $ref: "#/components/parameters/CursorParam"
      responses:
        "200":
          description: Callbacks matching the view.
          content:
            application/json:
              schema:
                type: object
                required:
                  - callbacks
                properties:
                  callbacks:
                    type: array
                    items:
                      $ref: "#/components/schemas/Callback"
                  next_cursor:
                    type:
                      - string
                      - "null"
                    description: Opaque cursor for the next page; `null` on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /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"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /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"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /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"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /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"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /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"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /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
                  - truncated
                properties:
                  agents:
                    type: array
                    items:
                      $ref: "#/components/schemas/Agent"
                  truncated:
                    type: boolean
                    description: >
                      `true` when there are more rows than the 2 000 agents the
                      listing returns: the response is a

                      prefix in the documented order, not the whole set.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
    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"
        "422":
          description: |
            The seat is refused and no row is written. Branch on `code`:

            * `seat_limit_reached` — the tenant already holds the `max_seats`
              active seats of the tier in force (`tier`). A seat over the cap is
              never billed as an extra: free a seat (an `inactive` one does not
              count) or move to a larger tier. The cap follows a tier change at
              once. A tenant that moved to a smaller tier keeps every seat it
              had, so `active_seats` can be above `max_seats`; only new seats
              are refused until it is back under. `enterprise` has no cap.
            * `unpriceable_tier` — the tenant's tier has no priced catalog row,
              so its cap cannot be read. The seat is refused rather than created
              without a cap.
          content:
            application/json:
              schema:
                anyOf:
                  - $ref: "#/components/schemas/SeatLimitReachedError"
                  - $ref: "#/components/schemas/ErrorEnvelope"
              examples:
                seat_limit_reached:
                  value:
                    error:
                      code: seat_limit_reached
                      message: tier starter allows 5 active seats and the tenant already has them
                      max_seats: 5
                      tier: starter
                      active_seats: 5
                unpriceable_tier:
                  value:
                    error:
                      code: unpriceable_tier
                      message: the tenant's billing tier has no priced catalog row
                        (tier_not_in_catalog)
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/agents/occupancy:
    get:
      operationId: getAgentOccupancy
      tags:
        - agents
      summary: Derived time by state and seat for an explicit window
      description: |
        Tenant-management surface (`dd_` or `ddu_`; an agent `ddw_` is 403).
        Returns `Dialer.Agents.Occupancy.by_agent/4` without a roster join or a
        client-facing recomputation: a seat with no interval evidence in the
        window is absent instead of appearing as a fabricated zero.

        The half-open window is `entered_at ∈ [from, to)`. A counted interval
        contributes its whole monotonic `duration_ms`; open and synthetic
        intervals are excluded and reported under `excluded`. Each seat carries
        an `occupancy` ratio under a named, versioned definition (`v1`):
        `(on_call + wrap_up) / (available + ringing + on_call + wrap_up)`, i.e.
        counted time minus `paused`. A later formula is published as a new
        definition next to `v1`; `v1` never changes meaning.
      parameters:
        - name: from
          in: query
          required: true
          schema:
            type: string
            format: date-time
          description: Inclusive start of the interval-entry window (RFC 3339).
        - name: to
          in: query
          required: true
          schema:
            type: string
            format: date-time
          description: Exclusive end of the interval-entry window (RFC 3339); must be
            after `from`.
        - name: agent_id
          in: query
          required: false
          schema:
            type: string
            format: uuid
          description: Optional seat filter, always inside the tenant resolved from the
            bearer.
      responses:
        "200":
          description: The occupancy derivation for seats with evidence in the window.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AgentOccupancyResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/agents/handle-time:
    get:
      operationId: getAgentHandleTime
      tags:
        - agents
      summary: Handle time per agent, crediting each agent only its legs of a
        transferred call
      description: >
        Tenant-management surface (`dd_` or `ddu_`; an agent `ddw_` is 403).

        Derived from the append-only call-control trail and the CDR clock

        (`Dialer.Calls.TransferChain.handle_time/4`, core#2403): a call

        transferred `A -> B -> C` is credited to A until the hand-off, to B from

        the hand-off to the next, and to C from there to the end — not whole to
        the

        agent the CDR row names. `control_ms` is the time holding the call,

        `consult_ms` the time on a consultation leg, `occupied_ms` their sum and

        `aht_ms` is `control_ms / calls` (`null` when the agent held none, never
        a

        fabricated zero). Calls are those that STARTED in the half-open window

        `[from, to)`. A leg whose duration is not measurable yet (the call is

        live) is left out and counted in `excluded.open_legs`. Control that left

        the agent population (an external transfer) credits no agent.


        The window may span at most 31 days (`400` otherwise) and at most 10,000

        calls (`422`: it is refused, never truncated). `definition: "v1"` never

        changes meaning; a later formula is published as `v2`.
      parameters:
        - name: from
          in: query
          required: true
          schema:
            type: string
            format: date-time
          description: Inclusive start of the call-start window (RFC 3339).
        - name: to
          in: query
          required: true
          schema:
            type: string
            format: date-time
          description: Exclusive end of the call-start window (RFC 3339); must be after
            `from`.
        - name: agent_id
          in: query
          required: false
          schema:
            type: string
            format: uuid
          description: Optional seat filter, always inside the tenant resolved from the
            bearer.
      responses:
        "200":
          description: Handle time for the agents that held or consulted on a call in the
            window.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AgentHandleTimeResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "422":
          $ref: "#/components/responses/Unprocessable"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
        "503":
          description: The evidence could not be read; retry. Fail-closed — never an empty
            answer.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
  /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"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
    patch:
      operationId: updateAgent
      tags:
        - agents
      summary: Edit a seat or release it (status inactive)
      description: >
        Corrects a seat created by `POST /v1/agents` and is the verb that
        releases

        it (core#2527). Admin only: a `dd_` key or a dashboard login with

        `account_admin`; any other login is `403 role_forbidden`. The body is

        closed: an unknown key is `400` naming it. Send only what changes.


        * `status: "inactive"` releases the seat: it stops counting against the
          plan cap and the invoice (`inactive` holds no seat), the seat's live
          `ddw_` tokens are revoked, it is checked out and its SIP credential is
          evicted (`sip_teardown`: `ok | skipped | failed`). The row is never
          deleted; `deactivated_at` is stamped by the database.
        * `status: "active"` on an inactive seat takes the same cap as creation:
          `422 seat_limit_reached` (atomic under the tenant lock) or
          `422 unpriceable_tier`.
        * `409 last_admin_seat` — the seat is the only active `admin` seat and
          the edit would demote or deactivate it.
        * `409 seat_linked_to_user` — the seat is linked to a console user and
          the edit changes its email. The link is kept, never re-pointed, so
          the address cannot change here.
        * `409 conflict` — the email or the extension is already taken.
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                email:
                  type: string
                role:
                  type: string
                  enum:
                    - agent
                    - supervisor
                    - admin
                sip_extension:
                  type: string
                status:
                  type: string
                  enum:
                    - active
                    - inactive
              additionalProperties: false
            example:
              status: inactive
      responses:
        "200":
          description: The seat after the edit.
          content:
            application/json:
              schema:
                type: object
                required:
                  - agent
                properties:
                  agent:
                    $ref: "#/components/schemas/Agent"
                  sip_teardown:
                    type: string
                    enum:
                      - ok
                      - skipped
                      - failed
                    description: Present when the seat was released or its extension changed; names
                      the result of evicting the extension the seat HAD.
        "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":
          description: |
            Reactivation refused: `seat_limit_reached` or `unpriceable_tier`
            (see `POST /v1/agents`). Nothing is written.
          content:
            application/json:
              schema:
                anyOf:
                  - $ref: "#/components/schemas/SeatLimitReachedError"
                  - $ref: "#/components/schemas/ErrorEnvelope"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/agents/{id}/skills:
    put:
      operationId: setAgentSkills
      tags:
        - agents
      summary: Replace the routing skills of an agent
      description: |
        Declares which skills the agent has (ADR #216 D3). The body REPLACES
        the whole set: a skill not listed is removed, one missing is added, and
        a repeated one counts once. A skill is a tenant label
        `^[a-z0-9][a-z0-9_-]{0,39}$` — no global catalog, no levels. `422
        unprocessable` when any skill is malformed, `skills` is not an array
        of strings, or there are more than 50; nothing is written then. An
        agent that is not in this tenant's roster is `404`. Writing works with
        `inbound_queue_skill_routing_enabled` OFF: what is stored stays inert
        until the tenant turns it on. A skill is a routing preference, not an
        authorization: no compliance gate reads it (ADR #216 D7). Admin only:
        a `dd_` key or a dashboard login with `account_admin`.
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required:
                - skills
              properties:
                skills:
                  type: array
                  maxItems: 50
                  items:
                    type: string
                    pattern: ^[a-z0-9][a-z0-9_-]{0,39}$
            example:
              skills:
                - idioma_es
                - cobranza_tardia
      responses:
        "200":
          description: The agent's skills after the write, sorted.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AgentSkills"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: |
            `role_forbidden`: a dashboard login without the `account_admin`
            role; `forbidden`: a widget token or a suspended tenant.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/Unprocessable"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
    get:
      operationId: getAgentSkills
      tags:
        - agents
      summary: The routing skills of an agent
      description: |
        The agent's skills, sorted; `[]` when it has none. An agent that is not
        in this tenant's roster is `404`. Admin only, like the write.
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: The agent's skills.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AgentSkills"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: |
            `role_forbidden`: a dashboard login without the `account_admin`
            role; `forbidden`: a widget token or a suspended tenant.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /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`).

        Role rules (core#2404): the password opens a `ddw_` bound to the seat,

        so a `dd_` key and an `account_admin` login pass, a `ddu_` passes only

        on the seat `users_agents.user_id` links to it, and every other login

        is `403 role_forbidden` (per-tenant knob `agent_credential_admin_only`,

        default on). 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/AgentCredentialForbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
    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`). Same role rules as the
        `POST` (core#2404): a `member` login is `403 role_forbidden` on any seat
        but its own.
      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/AgentCredentialForbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /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.


        When the cell runs its own TURN relay, `sip.turn` carries an ephemeral

        relay credential with the same horizon (`TurnIceServer`, core#1076);

        absent otherwise.


        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).


        WHICH seat is DERIVED FROM THE TOKEN, not from the body (core#1033,

        ADR #131). A dashboard login (`ddu_`) checks in the seat that

        `users_agents.user_id` links to that person. An agent browser token

        (`ddw_`) is 403 on this route by the widget allowlist

        (`Dialer.API.WidgetAllowlist`), as it is on the rest of `/v1/agents`.

        `sip_extension` is therefore OPTIONAL for a `ddu_` session, and sending

        one that is not the token's own seat is `403 seat_mismatch` — it is

        never honoured silently. A `dd_` tenant API key carries no person, so it

        still names the seat by `sip_extension` (the account-wide principal may

        already create and delete any seat).


        THE NOTICE GATE (core#2323, ADR #289). With the tenant's
        founder-governed flag

        `agent_monitoring_ack_enforced` on and at least one campaign that
        records

        (`record_calls`) and is not completed or archived, a seat that has not
        accepted

        the CURRENT recording-and-supervision notice (`POST
        /v1/agent-monitoring-acknowledgements`)

        gets `403 monitoring_ack_required` BEFORE anything changes: the seat
        stays

        offline and no SIP credential is minted. The body carries
        `error.notice_version`,

        the version to accept (`null` when none is published, which also closes
        the gate:

        there is nothing to accept). Born off.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                sip_extension:
                  type: string
                  description: >
                    REQUIRED with a `dd_` tenant key; optional with a `ddu_`
                    session, where

                    the seat comes from the token and a value that does not
                    match it is 403.
                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
                  turn:
                    urls:
                      - turn:turn.example.test:3478?transport=udp
                      - turns:turn.example.test:5349?transport=tcp
                    username: 1783332000:1b2c3d4e-5f6a-4b7c-8d9e-0f1a2b3c4d5e
                    credential: ephemeral-example-not-real
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/SeatFromToken"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "422":
          $ref: "#/components/responses/Unprocessable"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
        "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).


        Same seat derivation as `agentCheckin` (core#1033, ADR #131): the seat

        comes from the token for a `ddu_` session, so nobody can check a

        colleague out by naming their extension. A `dd_` tenant key still names

        it; a `ddw_` is 403 here by the widget allowlist.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                sip_extension:
                  type: string
                  description: >
                    REQUIRED with a `dd_` tenant key; optional with a `ddu_`
                    session, where

                    the seat comes from the token and a value that does not
                    match it is 403.
            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"
        "403":
          $ref: "#/components/responses/SeatFromToken"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /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) — unless `on_release:
        true`

        is sent: on a `ringing` / `on_call` seat that ARMS the pause (ADR #101
        D5,

        core#2542), the seat stays on its call and is answered with

        `pause_armed: true`; when the call is released (answered or not) the
        seat

        goes straight to `paused` with that reason, never through `available`.

        Arming again is idempotent (the last reason wins); `POST
        /v1/agents/{id}/resume`

        disarms it; a retirement armed by the session-cap sweep wins and
        discards

        it. `on_release` on a seat with no call behaves as the plain pause. 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
                on_release:
                  type: boolean
                  description: "Arm the pause for when the current call ends instead of refusing
                    with `409` (ADR #101 D5)."
            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"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /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. Leaving the pause is going `Ready` again, so it
        takes the same

        notice gate as the check-in: `403 monitoring_ack_required` when the gate
        is on and

        the agent has not accepted the current notice (see `POST
        /v1/agents/checkin`).
      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"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /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"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/agents/{id}/status:
    patch:
      operationId: agentSetStatus
      tags:
        - agents
      summary: Set a seat's presence by roster id (a FAÇADE over the seat FSM verbs)
      description: >
        NOT a second presence model and NOT a state write: the requested
        `status`

        is translated to the verb the seat FSM already has, and the FSM decides.

        Whatever it refuses is `409` with the seat's `current_state`; nothing is

        coerced. Target → verb (the seat is read at call time):


        | `status` | from `paused` | from `wrap_up` | otherwise |

        |---|---|---|---|

        | `available` | `POST /v1/agents/{id}/resume` | `POST
        /v1/agents/{id}/wrap-up` | `409` |

        | `paused` (needs `reason`) | `409` | `POST /v1/agents/{id}/pause` |
        `POST /v1/agents/{id}/pause` |

        | `offline` | `POST /v1/agents/checkout` | `POST /v1/agents/checkout` |
        `POST /v1/agents/checkout` |


        `available` from `offline` is `409` on purpose: the check-in derives the

        seat from the token and mints an ephemeral SIP credential

        (`POST /v1/agents/checkin`), which a by-id call must not hand to whoever

        names the seat. `ringing` and `on_call` are never a target: the FSM
        enters

        them only on a call. Presence events on the live floor
        (`agent.presence`)

        are the SAME frames the verbs above emit, because the same verb runs.


        Tenant scope only (a `ddw_` agent token is 403 by the widget allowlist).

        `available` from `paused` takes the same notice gate as `POST
        /v1/agents/{id}/resume`

        (`403 monitoring_ack_required`).

        An id from another tenant is `404`. `offline` additionally follows the

        author rule of the check-out: a `ddu_` login WITH a linked seat can only

        set its OWN seat (`403` otherwise).
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - status
              properties:
                status:
                  type: string
                  enum:
                    - available
                    - paused
                    - offline
                reason:
                  type: string
                  enum:
                    - manual
                    - break
                    - lunch
                    - training
                    - meeting
                  description: Required when `status` is `paused`; ignored otherwise.
            example:
              status: paused
              reason: lunch
      responses:
        "200":
          description: Status applied; `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":
          description: The seat FSM does not admit that move from the seat's current state.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AgentStatusConflict"
              example:
                error:
                  code: conflict
                  message: seat is on_call; the FSM has no transition to that status from here
                  current_state: on_call
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/wfm/coverage:
    get:
      operationId: getWfmCoverage
      tags:
        - wfm
      summary: Forecast, required and planned agents per 30-minute interval of one day
      description: |
        Tenant-management surface (`dd_` or `ddu_`; an agent `ddw_` is 403). A
        derived read (ADR #241): nothing is stored and every call recomputes
        from the append-only streams.

        **Forecast.** The same weekday of the previous 4 weeks (only days that
        have already ended), averaged per 30-minute interval of the UTC day.
        An inbound attempt is an ORIGINAL `call_attempts` row
        (`corrects_id IS NULL`) stamped `direction=inbound` that was not later
        corrected to `not_placed`; what is counted is what was OFFERED. AHT is
        (conversation + `wrap_up`) / answered; `wrap_up` comes from the closed
        `agent_state_intervals` of the seats, so for a seat that also dials out
        it is an upper bound. With fewer than 2 reference days or fewer than
        30 offered calls, `source` is `estimate` (a flat 6 calls per interval,
        AHT 300 s: a placeholder, not a prediction) instead of `history`.

        **Required agents.** Erlang C against the tenant's service target. The
        target is born absent and absent is inert: without it `target` and every
        interval's `staffing` carry `{inert: <reason>}` and `coverage` is null.
        Passing BOTH `threshold_ms` and `target_percent` simulates a target for
        this response only (never persisted; `simulated: true`). The search is
        capped at 5000 agents; a target not reached before the cap says
        `reachable: false`, and so does a 100 % target (the model only
        approaches it asymptotically).

        **Planned agents.** Full-time equivalents of the interval from the R11
        planned shifts (`paused` shifts excluded; one agent's overlapping
        shifts count once). `coverage.gap` is planned minus required.
      parameters:
        - name: date
          in: query
          required: true
          schema:
            type: string
            format: date
          description: UTC calendar day, `YYYY-MM-DD`, within 366 days of today.
        - name: threshold_ms
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 86400000
          description: Simulated wait threshold in ms (at most one day). Only valid
            together with `target_percent`.
        - name: target_percent
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 100
          description: Simulated target, percent of answered calls within `threshold_ms`.
            Only valid together with `threshold_ms`.
      responses:
        "200":
          description: The 48 intervals of the day.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WfmCoverageResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/planned-shifts:
    get:
      operationId: listPlannedShifts
      summary: List planned shifts for the tenant
      description: |
        Without `from`/`to`: the 500 shifts with the earliest `start_time`, and
        `truncated: true` when there are more. Shifts are append-only, so past
        the 500th a newer shift is NOT in this list: ask for its window.

        With `from` and `to` (RFC 3339, both or neither, `from` before `to`):
        every shift that overlaps `[from, to)`, with no cap, ordered by
        `start_time`; `truncated` is `false`.
      tags:
        - agents
      security:
        - tenantApiKey: []
      parameters:
        - name: from
          in: query
          required: false
          description: Start of the window (RFC 3339). Requires `to`.
          schema:
            type: string
            format: date-time
        - name: to
          in: query
          required: false
          description: End of the window (RFC 3339), exclusive. Requires `from`.
          schema:
            type: string
            format: date-time
      responses:
        "200":
          description: List of planned shifts
          content:
            application/json:
              schema:
                type: object
                required:
                  - shifts
                  - truncated
                properties:
                  shifts:
                    type: array
                    items:
                      $ref: "#/components/schemas/PlannedShift"
                  truncated:
                    type: boolean
                    description: |
                      `true` when, without a window, there are more shifts than
                      the 500 returned. Always `false` with `from`/`to`.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
    post:
      operationId: createPlannedShift
      summary: Create a planned shift
      tags:
        - agents
      security:
        - tenantApiKey: []
      description: |
        Plans one shift for one agent (`agent_id`, `start_time`, `end_time`,
        optional `planned_state`). A missing `agent_id` is `400 invalid`.

        **Who may name whom** (ADR #133, ADR #134). The `agent_id` is bound by
        the PRINCIPAL, the same rule on every verb that reads one: a `dd_`
        machine key names any agent of its tenant; a `ddw_` only its own; a
        `ddu_` login session linked to an agent seat acts as THAT seat —
        `agent_id` may be omitted (filled with the seat) or repeat it, and
        naming another agent is `403 forbidden`; a `ddu_` session with NO
        linked seat may name another agent only with `users.role =
        account_admin` — a `member` is `403 role_forbidden` — once the tenant's
        catalog key `ddu_author_role_enforced` is on (born off: until then the
        seatless session keeps the `dd_` contract on this verb).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - start_time
                - end_time
              properties:
                agent_id:
                  type: string
                  format: uuid
                  description: The agent the shift is planned for. Required for a `dd_` key; a
                    `ddu_` session linked to a seat may omit it (its seat) and
                    may not name another.
                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"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/academy/completions:
    post:
      operationId: recordAcademyCompletion
      summary: Record ONE Academy milestone for an agent
      tags:
        - academy
      security:
        - tenantApiKey: []
        - widgetSessionToken: []
      description: |
        Appends one completion event (a lesson finished, a badge earned) to the
        tenant's append-only training stream. The row is the record a customer
        audits, so nothing here can be rewritten later: the table has no UPDATE
        or DELETE grant and a trigger refuses both.

        **Idempotent.** Completing the same item twice is not a second event —
        the FIRST record is returned unchanged, so `occurred_at` keeps saying
        when the agent actually got there.

        **`occurred_at` is NOT an input**: the server stamps it. A training
        record whose instant is chosen by the browser of the person being
        audited is not evidence.

        **Who may name whom** (ADR #134). A `ddw_` agent token writes its OWN
        record only (`agent_id` is taken from the token; naming another agent is
        `403`). A `dd_` machine key MUST name the agent — in the tenant lane
        there is no "me", and defaulting would attribute training to whoever
        happened to call (`400`). A `ddu_` login session linked to an agent seat
        writes as THAT seat (`agent_id` optional or its own; another agent is
        `403 forbidden`); a `ddu_` with NO linked seat MUST name the agent
        (`400`) and may name another only with `users.role = account_admin` —
        a `member` is `403 role_forbidden` — once the tenant's catalog key
        `ddu_author_role_enforced` is on (born off: until then it keeps the
        `dd_` contract).

        `item_id` is format-checked, not enumerated: the curriculum's source of
        truth is the dashboard, so a new lesson must not need a `core`
        migration. Lowercase alphanumerics plus `._-`, up to 64 characters.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - kind
                - item_id
              properties:
                agent_id:
                  type: string
                  format: uuid
                  description: Required for a `dd_` key and for a seatless `ddu_` session; for a
                    `ddw_` token, and for a `ddu_` session linked to a seat, it
                    may only be their own agent (and may be omitted).
                kind:
                  type: string
                  enum:
                    - lesson
                    - badge
                item_id:
                  type: string
                  description: Curriculum item id, e.g. `ch1.dashboard`.
                  pattern: ^[a-z0-9][a-z0-9._-]{0,63}$
            example:
              agent_id: 8ac7f2f9-1f2c-4f7a-9d3b-1f0a6c5d4e3b
              kind: lesson
              item_id: ch1.dashboard
      responses:
        "201":
          description: The milestone as recorded (the FIRST record, if it already existed).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AcademyCompletion"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/academy/progress:
    get:
      operationId: getAcademyProgress
      summary: An agent's DERIVED Academy state
      tags:
        - academy
      security:
        - tenantApiKey: []
        - widgetSessionToken: []
      description: |
        Folds the agent's completion stream into the state the Academy renders
        from. Chapter and level are deliberately NOT computed here: which lesson
        belongs to which chapter is the client's curriculum, and duplicating it
        server-side would make every new chapter a `core` deploy.

        A `ddw_` token reads its OWN progress (`agent_id` optional, and only its
        own is accepted); so does a `ddu_` login session linked to an agent seat
        (another agent is `403 forbidden`). A `dd_` key MUST pass `agent_id`;
        so must a `ddu_` with NO linked seat, and it may read another agent's
        only with `users.role = account_admin` — a `member` is `403
        role_forbidden` — once the tenant's catalog key
        `ddu_author_role_enforced` is on (born off: until then it keeps the
        `dd_` contract; ADR #134). An agent of another tenant reads as an EMPTY
        document — RLS returns zero rows, the handler compares no ids.
      parameters:
        - $ref: "#/components/parameters/AgentIdFilter"
      responses:
        "200":
          description: The derived progress document.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AcademyProgress"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/academy/transcript:
    get:
      operationId: getAcademyTranscript
      summary: The auditable training record — who completed what and when
      tags:
        - academy
      security:
        - tenantApiKey: []
      description: |
        The constancia itself: every milestone of every agent of the tenant,
        with the agent's name, email and role, over a window. This is the
        business deliverable of MT-CLI-20 — the report a customer that audits
        the training of its collectors asks for.

        The window is half-open — `from` inclusive, `to` EXCLUSIVE — like the
        billing reports, so a milestone at a period cut belongs to exactly one
        side. `from` defaults to the epoch and not to a trailing window: the
        question an auditor asks is "everything this collector ever completed",
        and the stream is bounded by construction (one row per agent and item,
        never more).

        Tenant scope only: this is the export of the whole tenant, asked for by
        whoever audits — not by the audited. A `ddw_` agent token is denied
        before the handler (`403`).

        `?format=csv` (or `Accept: text/csv`) returns the artifact with its own
        `X-Artifact-SHA256`, so an auditor who files it can later prove it is
        the one that was handed over.
      parameters:
        - $ref: "#/components/parameters/FromFilter"
        - $ref: "#/components/parameters/ToFilter"
        - $ref: "#/components/parameters/AgentIdFilter"
        - $ref: "#/components/parameters/CsvFormat"
      responses:
        "200":
          description: The transcript (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/AcademyTranscript"
            text/csv:
              schema:
                type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /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`).


        WHO may act AS that extension (ADR #135, core#1037 phase 3): the agent

        behind `supervisor_ext` is the agent the body names and is bound by the

        same principal rule as every `agent_id` verb, decided BEFORE the role

        check and evidenced the same way. A `ddu_` login linked to a seat may
        name

        only its own seat (`403 forbidden`, no flag); a `ddu_` login without a
        seat

        may name a third party only as `account_admin` — a `member` is `403`

        `role_forbidden` once the tenant's `ddu_author_role_enforced` is on
        (born

        off). A `dd_` key names anyone. The same gate applies to `whisper`,
        `barge`,

        `takeover` and `POST /v1/ai/conversations/{id}/takeover`.
      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"
        "422":
          $ref: "#/components/responses/Unprocessable"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
        "502":
          $ref: "#/components/responses/SupervisionFailed"
        "503":
          $ref: "#/components/responses/SupervisionUnavailable"
  /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. Only the
        agent principal also gets `debtor_name` on each call (the widget's call
        card); see the `Call` schema.
      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"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
    post:
      operationId: createCall
      tags:
        - calls
      summary: Place one outbound call from your own program (no campaign, no account)
      description: >
        A developer's outbound call (core#2848, ADR #352). It enters the WHOLE
        voice chain through the

        same `Originator.dial/2` as every other call: Consent → DNC → QuietHours
        → RegF → StateMatrix →

        AIDisclosure → ArtificialVoice → DidAttestation → Quarantine run in
        full, in their order, with

        no flag and no subset, plus three policies placed before the originator
        holds anything. Nothing

        in the body can relax a gate.


        **v1 plays one thing: a synthesized message (`tts`).** The platform
        composes the tenant's stored

        artificial-voice disclosure, with the opt-out key where its
        `{optout_digit}` marker sits (the

        key is `9` on this route and is not a field of the body), followed by
        your `text`. It is

        artificial voice, so the gate demands the destination's consent AND the
        stored disclosure. The

        order of a first call is therefore: `PUT
        /v1/artificial-voice-disclosure`, then

        `PUT /v1/consents/{phone}` (with its `evidence_ref`: consent is never
        declared inline), then

        this route.


        `purpose` is required and is evidence, never a relaxation:
        `transactional`, `informational` or

        `marketing`. A `marketing` call with a synthesized voice is refused
        every time

        (`422 compliance_blocked`, `rule_id`
        `tcpa.marketing_artificial_voice.pewc_pending.v1`) until

        counsel settles prior express consent against prior express written
        consent. Destinations

        outside the North American Numbering Plan are refused
        (`egress.destination_not_nanp.v1`); so is

        a destination class that is locked for the tenant (core#2844).


        **Answers.** `202` means the call is accepted and being placed: follow
        it with

        `GET /v1/calls/{call_id}` and the call webhooks. `status` is `dialing`,
        or `unresolved` when

        the switch did not answer in time — the leg may be ringing, so DO NOT
        retry (it would dial the

        number twice). `422 compliance_blocked` carries `gate`, `rule_id` and
        `decision_id`: it is

        evidence and repeating it unchanged refuses again. `402`
        (`plan_inactive`, `wallet_insufficient`,

        `plan_exhausted`, `exposure_limit`) means no money, and NO call left.
        `422 no_rate` is not a

        payment refusal: topping up changes nothing. Before the originator, and
        with no call placed:

        `422 disclosure_missing`, `422 optout_instructions_missing`, `422
        caller_id_unavailable`

        (`from` must be an active, verified number of the tenant, never a free
        value; absent, the

        first usable number is taken), and `503 prompt_unavailable` / `503
        egress_unavailable`, which

        are safe to retry because nothing was sent. `429 render_rate_limited`
        means the tenant spent its

        per-minute budget of message renders that miss the audio cache (no
        render, no call, no evidence

        row): retry later or reuse a text already rendered. A `test` API key is
        refused with

        `403 test_environment`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateCallRequest"
            example:
              to: "+13125550100"
              purpose: transactional
              text: Your appointment is tomorrow at ten.
      responses:
        "202":
          description: The call was accepted and is being placed (`dialing`), or its
            originate was not confirmed in time (`unresolved`; do not retry).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CreateCallResult"
              example:
                call_id: 6f0a1b2c-3d4e-5f6a-7b8c-9d0e1f2a3b4c
                status: dialing
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          description: No money, and no call left — `plan_inactive`,
            `wallet_insufficient`, `plan_exhausted` or `exposure_limit`. Top up
            or upgrade, then call again.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: wallet_insufficient
                  message: prepaid balance is insufficient for this call
        "403":
          $ref: "#/components/responses/Forbidden"
        "422":
          description: >
            Refused, no call placed. `compliance_blocked` (a gate or a policy of
            the originator's prefix,

            with `gate`, `rule_id` and `decision_id`), `no_rate`,
            `disclosure_missing`,

            `optout_instructions_missing` or `caller_id_unavailable`.
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: "#/components/schemas/ComplianceBlockedError"
                  - $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: compliance_blocked
                  message: the compliance engine refused this call (tenant_policy)
                  gate: tenant_policy
                  rule_id: egress.destination_not_nanp.v1
                  decision_id: 2c1d0e9f-8a7b-6c5d-4e3f-2a1b0c9d8e7f
        "429":
          $ref: "#/components/responses/Throttled"
        "502":
          $ref: "#/components/responses/DialError"
        "503":
          description: Nothing was sent and the request is safe to retry — the message
            audio could not be rendered, no directed route is available, or the
            call could not be started.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: prompt_unavailable
                  message: the message audio could not be rendered; no call was placed
  /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`. It also carries
        `transfer_chain`,

        the legs of the call in order (core#2403) — `[]` when nobody transferred

        it; a chain that cannot be read is `503`, never `[]`.
      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/CallDetail"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
        "503":
          description: The evidence could not be read; retry. Fail-closed — never an empty
            answer.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
  /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"
  /v1/calls/{call_id}/transfer:
    post:
      operationId: callBlindTransfer
      tags:
        - calls
      summary: Blind-transfer a live call to an internal or external destination
      description: >
        Moves the CONSUMER leg to another destination (`uuid_transfer`),

        evidence-first; the origin agent's leg dies with the

        broken bridge. The call is handed over immediately — there is no

        consultation leg and no way back — when the agent needs to speak to the

        destination first, that is the attended transfer below.


        `destination` is one of THREE classes, each gated on its own terms:


        - a roster `sip_extension` or an agent id (UUID) of an ACTIVE agent of
          the caller's tenant whose seat is FREE right now;
        - a roster extension with no agent behind it;

        - an EXTERNAL PSTN number in strict E.164 (`+<country><number>`), or
          the id of one of the tenant's CURRENT registered external
          destinations — core resolves it to its E.164 and it is gated exactly
          as that number (`GET /v1/transfer/targets` lists them WITHOUT the
          number, which is what a widget picker sends).

        The roster is consulted FIRST, so a tenant whose extensions happen to

        be 10 digits long still transfers internally. A number-shaped string

        that is not E.164 is refused `422` `destination_not_supported`: the

        jurisdiction that decides whether the new leg may be placed is resolved

        FROM the destination number, and a national string with no country

        cannot answer it.


        **An external destination must be REGISTERED first**

        (`POST /v1/transfer-destinations`). Any other E.164 is refused `422`

        `compliance_blocked`, `gate: pre_dial`, `rule_id`

        `transfer.destination_unregistered.v1`, before the chain below runs

        and with no `decision_id` (the engine never ran). A registry that

        cannot be read also blocks
        (`transfer.destination_registry_unavailable.v1`).

        A registered destination still passes the whole chain below.


        **An external transfer ORIGINATES A NEW LEG, so it passes the same

        GATE_PRE_DIAL chain a dial does** (consent, DNC, quiet hours, Reg F,

        state matrix, disclosure). It is evaluated as what it is — a call to the

        DESTINATION: DNC reads the destination's suppression status and quiet

        hours resolve the destination's own NPA. A block answers `422`

        `compliance_blocked` with `gate: pre_dial` and the engine `decision_id`;

        nothing is dialled. A non-NANP E.164 blocks: a USA cell has no

        quiet-hours window for it.


        What each gate of that chain asks of the destination, and what lets it

        through:


        - **Consent** (`tcpa.consent.v1`). A consent revocation on file for the
          number blocks (`consent_revoked`). Otherwise prior express consent is
          required, as for a mobile, unless one of three things holds: consent
          on file for the number; the `line_type` the tenant DECLARED for the
          registered destination is `landline`; or the tenant's human-initiated
          posture (`human_initiated_enabled`, OFF by default, admin-only) is on.
          `unknown` — the default of a registration — `mobile` and `voip`
          require it (`tcpa_no_consent`).
        - **DNC** (`dnc.lists.v1` and the Reassigned Numbers Database). The
          destination must be on no suppression list, and the RND must answer
          that it was not reassigned since the registration instant. The
          declared `line_type` changes nothing here.
        - **Quiet hours** — the destination's own window, from its NPA.

        - **Reg F and the state matrix** read the attempt history of the
          request's consumer and debt, which this leg leaves EMPTY: the party
          at the other end is not the debtor.
        - **Disclosure and artificial voice** — nothing prerecorded or
          synthetic plays on a transfer, so they have nothing to require.

        **Caller-ID on an external leg is the DID this call already presents** —

        never chosen at transfer time, because caller-ID is round-robin over the

        pool the operator configured. A call with no tenant DID to present is

        refused `422` `caller_id_unavailable` rather than dialled with the

        carrier gateway's default number.


        **The external leg is its own call attempt, billed only if it bridges.**

        Before the switch is touched the leg gets its own `call_attempts` row

        (no debtor on it: it never counts against the debtor's Reg F attempts)

        and its own listener. What is charged is `outbound_minutes` from the

        leg's `CHANNEL_BRIDGE` to its hangup, rated in Decimal like any dial —

        never from the `uuid_transfer` acknowledgement, which comes before the

        far end rings. A leg that never bridged closes with no `answered_at`

        and costs nothing.


        **A house leg leaves by a carrier the LCR chose, metered against that

        carrier's contracted call rate.** The same route selection a dial uses

        picks the leg's carrier; the route is recorded with the leg's attempt
        and

        travels to the edge as the carrier to use. A house leg with no route
        (routing

        switched off, no candidate carrier) is refused `503` `no_route`, and a

        carrier already at its contracted calls-per-second refuses it `429`

        `carrier_cps`. Both happen before anything is written or dialled, so the

        consumer stays with their agent. A BYOC tenant's leg goes out through
        its own

        trunk, with no route and no carrier meter.


        Gated like `unhold` on the CONSUMER's side too: the call comes back to

        live audio, so the GATE_MID_CALL trio (consent revocation,

        cease-and-desist, quiet hours) is re-checked BEFORE the switch is

        touched and refuses with `422` `compliance_blocked`, `gate: mid_call`.

        And because this phase does NOT move the agent seat, an INTERNAL

        destination whose seat is busy is refused `422`

        `destination_unavailable` rather than double-booked.


        On success a `control_handoff` row is stamped: it names the DESTINATION

        agent for an internal transfer (from then on the ownership gate

        authorizes that agent and refuses the origin), and carries no agent for

        an external one — control left the agent population, so no agent-scoped

        principal may drive the call afterwards. The call's `agent_id` is never

        touched; it keeps saying where the call started.
      security:
        - tenantApiKey: []
        - userSessionToken: []
        - widgetSessionToken: []
      parameters:
        - $ref: "#/components/parameters/CallId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/BlindTransferRequest"
      responses:
        "200":
          description: Transfer issued (audit row durable, handoff stamped).
          content:
            application/json:
              schema:
                type: object
                required:
                  - call
                properties:
                  call:
                    $ref: "#/components/schemas/CallControlResult"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          description: >
            The tenant's plan is not active (or could not be read —
            fail-closed).

            An EXTERNAL transfer buys carrier minutes exactly like a dial, so it

            passes the same plan traffic gate a dial does (ADR #39). An INTERNAL

            transfer never earns this: moving a call between two agents of the

            same tenant buys nothing from a carrier. An EXTERNAL transfer also

            passes the SpendGuard brake a dial passes (core#2846): `code`

            `wallet_insufficient`, `plan_exhausted`, `exposure_limit` or

            `daily_spend_limit` refuse it before anything is written or dialled.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: plan_inactive
                  message: tenant plan is not active; traffic is gated
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/TransferRefused"
        "429":
          $ref: "#/components/responses/TransferThrottled"
        "502":
          $ref: "#/components/responses/CallControlFailed"
        "503":
          $ref: "#/components/responses/TransferUnavailable"
  /v1/calls/{call_id}/transfer/attended/start:
    post:
      operationId: callAttendedTransferStart
      tags:
        - calls
      summary: Open an attended-transfer consultation
      description: |
        Puts the CONSUMER on HOLD MUSIC and opens a private consultation leg to
        the destination so the agent can speak to them BEFORE handing the call
        over. Nothing is transferred yet and no bridge is broken: the consumer
        stays bridged to their agent for the whole consultation, which is what
        makes `.../cancel` a restoration rather than a repair.

        WHAT EACH PARTY HEARS, stated rather than implied. The consumer hears
        hold music — the same MOH the `hold` verb streams, not silence. The
        destination is audible to the AGENT ONLY (the consultation leg is the
        supervision whisper leg), so nothing said during the consultation
        reaches the consumer. The destination, being on a leg that hears the
        call, can hear a consumer who keeps talking: that is the consultation
        working as intended, and it is stated here so it is not discovered.

        `destination` is validated exactly like the blind transfer's: an ACTIVE
        agent of THIS tenant, by roster extension or agent id, whose seat is
        free right now. External/PSTN-shaped destinations are refused `422`
        `destination_not_supported` (this phase is internal-only).

        NOT gated by GATE_MID_CALL, deliberately: this verb only ever takes the
        consumer's audio AWAY, exactly like `hold`. The gate runs on
        `.../complete` and `.../cancel`, which are the verbs that put them back
        on live audio.

        ONE CONSULTATION PER CALL: a second `start` while one is open is `409`
        `consultation_in_progress`. If the destination does not take the
        consultation leg, the hold is RELEASED before the `422`
        `consultation_unanswered` is returned — the consumer is back with their
        agent before the caller reads the error.
      security:
        - tenantApiKey: []
        - userSessionToken: []
        - widgetSessionToken: []
      parameters:
        - $ref: "#/components/parameters/CallId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/BlindTransferRequest"
      responses:
        "200":
          description: Consultation open (audit row durable, consultation leg up).
          content:
            application/json:
              schema:
                type: object
                required:
                  - call
                properties:
                  call:
                    $ref: "#/components/schemas/CallControlResult"
        "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/TransferRefused"
        "429":
          $ref: "#/components/responses/CallControlThrottled"
        "502":
          $ref: "#/components/responses/CallControlFailed"
        "503":
          $ref: "#/components/responses/CallControlUnavailable"
  /v1/calls/{call_id}/transfer/attended/complete:
    post:
      operationId: callAttendedTransferComplete
      tags:
        - calls
      summary: Complete the attended transfer — hand the call to the destination
      description: |
        Hangs the consultation leg up and moves the CONSUMER leg to the
        destination, the same `uuid_transfer` the blind transfer runs. A
        `control_handoff` is stamped naming the DESTINATION agent: from that
        row on the ownership gate authorizes them and refuses the origin agent,
        without touching the call's `agent_id`.

        NO BODY. The destination comes from the OPEN consultation, never from
        the request, so a caller cannot consult with one agent and complete to
        another. It IS re-resolved: a destination deactivated since `start`
        is refused `422` rather than handed a call.

        Gated like the blind transfer (the consumer lands on live audio with a
        new party), so the GATE_MID_CALL trio runs BEFORE the switch is touched
        and refuses `422` `compliance_blocked` with the consultation left open.
        `.../cancel` runs the SAME gate, so it is refused too while the block
        lasts: the consumer stays on hold with the C9 auto-hangup armed,
        exactly as a blocked `unhold` leaves them.

        IF THE TRANSFER ITSELF FAILS the hold is released and the consumer goes
        back to the agent who was already attending them — never to nobody —
        and no handoff is stamped for a transfer that did not happen.

        `200` MEANS THE SWITCH TOOK THE TRANSFER, NOT THAT THE DESTINATION
        ANSWERED. The origin agent's leg is gone by then, so the response
        carries `call.bridge_confirm_by`: the instant by which the consumer is
        bridged to the destination, or has hung up, or is hung up by core with
        cause `NORMAL_UNSPECIFIED` (in the CDR and the `call.ended` webhook,
        where the call still closes as answered) and an audit row `hangup` /
        `attended_transfer_unbridged`.
        It is never left on the line alone. `null` means core could not arm that
        check; the audit row of this `complete` then says `bridge_net_unarmed`.

        `409` `no_consultation` when there is no open consultation on this call.
      security:
        - tenantApiKey: []
        - userSessionToken: []
        - widgetSessionToken: []
      parameters:
        - $ref: "#/components/parameters/CallId"
      responses:
        "200":
          description: Transfer issued (handoff stamped, consultation closed, bridge check
            armed).
          content:
            application/json:
              schema:
                type: object
                required:
                  - call
                properties:
                  call:
                    $ref: "#/components/schemas/CallControlResult"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "422":
          $ref: "#/components/responses/TransferRefused"
        "429":
          $ref: "#/components/responses/CallControlThrottled"
        "502":
          $ref: "#/components/responses/CallControlFailed"
        "503":
          $ref: "#/components/responses/CallControlUnavailable"
  /v1/calls/{call_id}/transfer/attended/cancel:
    post:
      operationId: callAttendedTransferCancel
      tags:
        - calls
      summary: Cancel the consultation — the call stays with its agent
      description: |
        Hangs the consultation leg up and takes the consumer off hold. THE CALL
        IS LEFT EXACTLY AS IT WAS: the consumer never left the bridge, so this
        is a restoration by construction, not a rebuild — the music stops and
        they are back with the agent who was already attending them.

        NO HANDOFF IS STAMPED, and that is the point rather than an omission:
        control never MOVED during a consultation (`.../start` deliberately
        does not stamp), so the origin agent kept the call throughout and there
        is nothing to give back. The `attended_transfer_cancel` audit row is the
        durable evidence that the consultation ended.

        NO BODY. Gated like `unhold` (the consumer returns to live audio), so a
        `422` `compliance_blocked` leaves them on hold with the C9 auto-hangup
        armed, exactly as a blocked `unhold` does.

        A consultation leg that is already gone (the destination hung up) does
        NOT fail this verb: tearing it down is best-effort, because the verb's
        real work is giving the consumer their agent back. If the ORIGIN AGENT
        hung up during the consultation there is no leg to take off hold and no
        agent to return to, so this answers `409` `not_bridged` — completing
        the transfer is then the safe way out.

        `409` `no_consultation` when there is no open consultation on this call.
      security:
        - tenantApiKey: []
        - userSessionToken: []
        - widgetSessionToken: []
      parameters:
        - $ref: "#/components/parameters/CallId"
      responses:
        "200":
          description: Consultation cancelled; the call continues with its agent.
          content:
            application/json:
              schema:
                type: object
                required:
                  - call
                properties:
                  call:
                    $ref: "#/components/schemas/CallControlResult"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "422":
          $ref: "#/components/responses/TransferRefused"
        "429":
          $ref: "#/components/responses/CallControlThrottled"
        "502":
          $ref: "#/components/responses/CallControlFailed"
        "503":
          $ref: "#/components/responses/CallControlUnavailable"
  /v1/calls/{call_id}/conference/add:
    post:
      operationId: callConferenceAdd
      tags:
        - calls
      summary: Bring a colleague, or a registered external number, into the call
        (three-way conference)
      description: >
        Rings a roster colleague of THIS tenant and puts them on the call's

        audio, so the agent, the consumer and the colleague talk together.

        An external/PSTN destination is `422` `destination_not_supported` unless

        the tenant has the `conference_external_enabled` flag ON (default OFF,

        and it needs `conference_enabled`).


        EXTERNAL THIRD PARTY (core#1700, ADR #223 D6/D8). With that flag ON an

        E.164 goes through the SAME chain an external blind transfer does — plan

        gate, egress allowlist, the tenant's caller-ID, the registry of

        `/v1/transfer-destinations` (`reason_code` `destination_unregistered`,

        no `decision_id`) and the GATE_PRE_DIAL chain over the DESTINATION

        (DNC, quiet hours; `422` `compliance_blocked` with a queryable

        `decision_id`) — plus the consumer's attestation: the body must carry

        `consumer_agreed: true`, or the answer is `422`

        `consumer_agreement_required` BEFORE anything is evaluated or dialled.

        The attestation is recorded in the call-control audit row and on the

        third party's own attempt. An external number is refused with zero

        commands to the switch whenever any of those refuses.


        THE EXTERNAL LEG is the carrier leg an external transfer has: it leaves

        with the tenant's DID by the carrier the LCR chose for a house tenant

        (or the tenant's own trunk for BYOC), metered against that carrier's

        contracted call rate (`429` `carrier_cps`, `503` `no_route`,

        `503` `capacity_unavailable`, as for a transfer). Unlike a transfer it

        replaces nobody, so it is ONE MORE CHANNEL of the tenant: a tenant at

        its channel ceiling or call-rate window is refused `429`

        `tenant_channels` / `tenant_cps` with nothing written or dialled. It is

        billed from ITS OWN answer, never from the command's acknowledgement.


        BEHIND THE `conference_enabled` FLAG, per tenant. With the flag off this

        path answers `404` `not_found` — the same answer as a path that does not

        exist — before the body is read, the rate budget is spent or the call is

        looked up, and nothing else on the call surface changes.


        WHAT EACH PARTY HEARS, stated rather than implied. The colleague hears

        the CONSUMER's channel and joins it as a participant, so the consumer
        and

        the agent can hear them. WHAT IS RECORDED: the colleague's voice is

        mixed into the consumer's recording (the same channel), it is not a

        separate track and cannot be separated afterwards. Whether the consumer

        must be told, and what consent applies to a recording that now contains
        a

        third voice, is an open compliance question tracked in `core#1648`; this

        phase adds no disclosure.


        ONE CONFERENCE PER CALL, and never on top of a consultation: a second

        `add` is `409` `conference_in_progress`, and so is `add` while an

        attended-transfer consultation is open (`409`
        `consultation_in_progress`).

        A blind transfer or an attended-transfer `start` asked while a
        conference

        is open is `409` `conference_in_progress` as well.


        GATED like a transfer: the GATE_MID_CALL trio (quiet hours, consent

        revocation, cease and desist) is re-checked, and a tenant whose plan

        cannot be read is refused (`402` `plan_inactive`, fail-closed). The

        third party's leg is its own `call_attempts` row (`leg` = `conference`,

        `conference_kind` `internal` or `external`), which belongs to no debtor

        and no agent. An internal colleague's leg has no carrier and is not

        billed; an external leg carries its carrier and is billed from its own

        answer.


        NOT REACHABLE WITH A WIDGET (`ddw_`) TOKEN in this phase: the widget

        allowlist is default-deny and does not list these paths, so that token
        is

        refused `403`. Opening them to the widget is a separate, explicit
        decision.


        THE AGENT KEEPS THE CALL. Ownership is the same ADR #66 gate as every

        other verb: an agent who no longer controls the call is refused `403`.
      security:
        - tenantApiKey: []
        - userSessionToken: []
      parameters:
        - $ref: "#/components/parameters/CallId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ConferenceAddRequest"
      responses:
        "200":
          description: Conference open (audit row durable, the colleague's leg up).
          content:
            application/json:
              schema:
                type: object
                required:
                  - call
                properties:
                  call:
                    $ref: "#/components/schemas/CallControlResult"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          description: |
            An EXTERNAL conference party is refused by the plan gate
            (`plan_inactive`) or by the SpendGuard brake a dial passes
            (core#2846): `wallet_insufficient`, `plan_exhausted`,
            `exposure_limit` or `daily_spend_limit`. Nothing was dialled.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: wallet_insufficient
                  message: the account cannot cover this leg; the third party was not dialled
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "422":
          $ref: "#/components/responses/TransferRefused"
        "429":
          $ref: "#/components/responses/TransferThrottled"
        "502":
          $ref: "#/components/responses/CallControlFailed"
        "503":
          $ref: "#/components/responses/TransferUnavailable"
  /v1/calls/{call_id}/conference/remove:
    post:
      operationId: callConferenceRemove
      tags:
        - calls
      summary: Take the colleague out of the call
      description: >
        Hangs up the colleague's own leg. The consumer and the agent are left

        exactly as they were: this verb only ever takes a voice AWAY, so it is

        never gated by compliance or by the plan.


        NO BODY. The leg to hang up comes from the OPEN conference, never from

        the request. A leg the switch no longer knows (the colleague already

        hung up) is the state this verb was asked to reach, so it still answers

        `200`. `409` `no_conference` when this call has no conference open.


        BEHIND THE `conference_enabled` FLAG: with it off this path answers
        `404`

        `not_found` like a path that does not exist.
      security:
        - tenantApiKey: []
        - userSessionToken: []
      parameters:
        - $ref: "#/components/parameters/CallId"
      responses:
        "200":
          description: Conference closed; the call continues with its agent.
          content:
            application/json:
              schema:
                type: object
                required:
                  - call
                properties:
                  call:
                    $ref: "#/components/schemas/CallControlResult"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "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}/recording/pause:
    post:
      operationId: callRecordingPause
      tags:
        - calls
      summary: Mask the recording (silence) while a card is spoken
      description: >
        Makes FreeSWITCH write SILENCE into the call's recording from now on

        (`uuid_record mask`): the file keeps its length and what is said while

        the mask is on never reaches the stored audio, in either direction. The

        interval is derived from the call's append-only `pause_recording` /

        `resume_recording` audit rows (`started_at`, `ended_at`, the agent;

        times only, never content), so an audit can see that a mask happened.
        The

        voice-AI transcript is NOT touched by this verb.


        IDEMPOTENT: a second `pause` while the mask is on opens no second

        interval. A transferred call keeps its mask.


        BEHIND THE `recording_pause_enabled` FLAG, per tenant: with it off this

        path answers `404` `not_found` like a path that does not exist, before
        the

        call is looked up or any audit row is written.


        Same ownership gate, rate brake and `call_control_actions` evidence row
        as

        the other call-control verbs, with a stricter ownership rule for THIS

        verb (core#2751): an agent token (`ddw_`) and a console session (`ddu_`,

        through its linked seat) act only on the call they control, and a
        session

        with no seat acts on none (`403` otherwise, the row stays `failed`). The

        tenant key (`dd_`) keeps account-wide control and its row says

        `actor=tenant_key`, not the call's agent. `409` `not_recording` when the
        call has

        no live recording (the campaign does not record, the tenant policy is
        off,

        or the audio is already closed). NO BODY.
      security:
        - tenantApiKey: []
        - userSessionToken: []
        - widgetSessionToken: []
      parameters:
        - $ref: "#/components/parameters/CallId"
      responses:
        "200":
          description: The recording is masked from now on.
          content:
            application/json:
              schema:
                type: object
                required:
                  - call
                properties:
                  call:
                    $ref: "#/components/schemas/CallControlResult"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "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}/recording/resume:
    post:
      operationId: callRecordingResume
      tags:
        - calls
      summary: Lift the recording mask
      description: >
        Lifts the mask: the recording captures audio again from now on. Closes
        the

        open interval of the call's mask. IDEMPOTENT: with no mask on it

        changes nothing and still answers `200`.


        BEHIND THE `recording_pause_enabled` FLAG, per tenant: with it off this

        path answers `404` `not_found` like a path that does not exist, before
        the

        call is looked up or any audit row is written.


        Same ownership gate, rate brake and `call_control_actions` evidence row
        as

        the other call-control verbs, with a stricter ownership rule for THIS

        verb (core#2751): an agent token (`ddw_`) and a console session (`ddu_`,

        through its linked seat) act only on the call they control, and a
        session

        with no seat acts on none (`403` otherwise, the row stays `failed`). The

        tenant key (`dd_`) keeps account-wide control and its row says

        `actor=tenant_key`, not the call's agent. `409` `not_recording` when the
        call has

        no live recording (the campaign does not record, the tenant policy is
        off,

        or the audio is already closed). NO BODY.
      security:
        - tenantApiKey: []
        - userSessionToken: []
        - widgetSessionToken: []
      parameters:
        - $ref: "#/components/parameters/CallId"
      responses:
        "200":
          description: The recording captures audio again.
          content:
            application/json:
              schema:
                type: object
                required:
                  - call
                properties:
                  call:
                    $ref: "#/components/schemas/CallControlResult"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "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}/stream:
    post:
      operationId: callStreamOpen
      tags:
        - calls
      summary: Stream the audio of a live call to your own wss:// endpoint
      description: >
        Opens a stream of the call's audio towards a `wss://` endpoint of yours

        (core#2862, ADR #355), so you can bring your own speech or AI. The audio
        is

        PCM 16-bit linear, mono, 8000 Hz. `direction`: `listen` (default) only
        sends

        the call's audio to you; `both` also accepts audio from you, injected
        into

        the call.


        THE STREAM COUNTS AS A RECORDING. It is opened only when the call is
        live,

        has a live recording whose consent evidence was frozen as GRANTED (the
        same

        evidence the recording carries, including the two-party-consent states),
        and

        has no recording mask open (`409` `recording_masked`). It is CLOSED when
        the

        recording is masked (`POST /v1/calls/{call_id}/recording/pause`) or the
        call

        ends; resuming the recording does not reopen it, you open a new one. A
        call

        with no live recording answers `409` `not_recording`; a recording with
        no

        granted consent on file answers `422` `stream_consent_required`; a
        second

        open on the same call answers `409` `stream_already_open`.


        The destination must be a `wss://` URL on port 443 that resolves only to

        public addresses (`422` `destination_rejected` otherwise; credentials in
        the

        URL or a fragment are refused). We never store your URL, only its host
        and a

        hash. FreeSWITCH never connects to it: the platform's relay does, to the

        address it vetted, over verified TLS. The first text frame of the
        connection

        is a `start` event with `stream_id`, `call_id` and a per-stream token.


        BEHIND THE `developer_audio_stream_enabled` FLAG, per tenant: with it
        off this

        path answers `404` `not_found` like a path that does not exist. A tenant

        `dd_` key only (`403` for a console session or an agent token, and `403`

        `synthetic_tenant` for a demo tenant, which puts no traffic outside).
        While the

        platform has no relay configured the stream is refused with `503`

        `stream_unavailable` and no audio leaves the call.
      security:
        - tenantApiKey: []
      parameters:
        - $ref: "#/components/parameters/CallId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - url
              properties:
                url:
                  type: string
                  maxLength: 2048
                  description: Your `wss://` endpoint (port 443, public address, no credentials in
                    the URL).
                direction:
                  type: string
                  enum:
                    - listen
                    - both
                  default: listen
      responses:
        "201":
          description: The stream is open.
          content:
            application/json:
              schema:
                type: object
                required:
                  - stream
                properties:
                  stream:
                    $ref: "#/components/schemas/CallStream"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: |
            `code` `call_not_live`, `not_recording`, `recording_masked` or
            `stream_already_open`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: not_recording
                  message: the call has no live recording
        "422":
          description: |
            `code` `destination_rejected` (the URL is not an acceptable `wss://`
            destination) or `stream_consent_required` (the call recording has no
            granted consent on file).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: stream_consent_required
                  message: the recording has no granted consent on file
        "429":
          $ref: "#/components/responses/CallControlThrottled"
        "502":
          description: The result of the stream could not be handled (`code`
            `stream_error`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "503":
          description: >
            `code` `stream_unavailable`: the platform has no relay for call
            streams or

            the fork did not start. Nothing was streamed. A call whose audio is
            already

            forked (the live copilot or a voice-AI agent: one fork per call)
            keeps

            answering 503 until that fork ends.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: stream_unavailable
                  message: call streaming is not available
    delete:
      operationId: callStreamClose
      tags:
        - calls
      summary: Close the audio stream of a call
      description: >
        Closes the open stream of the call (core#2862, ADR #355). IDEMPOTENT:
        with no

        stream open it changes nothing and answers `200` with `closed: false`.


        BEHIND THE `developer_audio_stream_enabled` FLAG, per tenant (`404` when
        off).

        A tenant `dd_` key only.
      security:
        - tenantApiKey: []
      parameters:
        - $ref: "#/components/parameters/CallId"
      responses:
        "200":
          description: The stream is closed (or there was none).
          content:
            application/json:
              schema:
                type: object
                required:
                  - stream
                properties:
                  stream:
                    type: object
                    required:
                      - call_id
                      - closed
                    properties:
                      call_id:
                        type: string
                        format: uuid
                      closed:
                        type: boolean
                      stream_id:
                        type:
                          - string
                          - "null"
                        format: uuid
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/CallControlThrottled"
  /v1/rewind-segments:
    get:
      operationId: listRewindSegments
      tags:
        - rewind-segments
      summary: The tenant's saved rewind segments
      description: >
        The live segments of the tenant, by name. Retired ones are not

        listed. A segment is a NAMED SET OF FILTERS, not a list of accounts:
        using

        it (`segment_id` on the rewind) re-evaluates it against the data of that

        moment, so no second copy of PII exists.
      responses:
        "200":
          description: The live segments.
          content:
            application/json:
              schema:
                type: object
                required:
                  - rewind_segments
                properties:
                  rewind_segments:
                    type: array
                    items:
                      $ref: "#/components/schemas/RewindSegment"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
    post:
      operationId: createRewindSegment
      tags:
        - rewind-segments
      summary: Save a named set of rewind filters
      description: |
        `filters` goes through the SAME validator as the rewind itself
        (`Dialer.Campaigns.RewindFilters`): a filter set the rewind would refuse
        is `422` here, and an unrecognized key is `422` naming it. `name` is
        trimmed, 1 to 80 characters, and unique among the tenant's LIVE segments
        without regard to case (`409 conflict` otherwise; a retired name is
        reusable). Who saved it is taken from the credential. Every write leaves
        an append-only audit fact (created / edited / retired).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required:
                - name
                - filters
              properties:
                name:
                  type: string
                  minLength: 1
                  maxLength: 80
                filters:
                  $ref: "#/components/schemas/RewindFilters"
            example:
              name: Voicemail, 3+ attempts, last week
              filters:
                dispositions:
                  - no_answer
                  - voicemail
                max_attempts: 3
                last_attempt_older_than_days: 7
      responses:
        "201":
          description: Saved.
          content:
            application/json:
              schema:
                type: object
                required:
                  - rewind_segment
                properties:
                  rewind_segment:
                    $ref: "#/components/schemas/RewindSegment"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "409":
          $ref: "#/components/responses/Conflict"
        "422":
          $ref: "#/components/responses/Unprocessable"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/rewind-segments/{id}:
    patch:
      operationId: updateRewindSegment
      tags:
        - rewind-segments
      summary: Rename a segment or replace its filters
      description: >
        `filters`, when sent, REPLACES the stored set (it does not merge).
        Sending

        neither `name` nor `filters` is `400`; an edit that changes nothing is
        `200`

        and writes no audit fact. A retired, missing or another tenant's segment
        is

        `404`. Same validation and status codes as the `POST`.
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              properties:
                name:
                  type: string
                  minLength: 1
                  maxLength: 80
                filters:
                  $ref: "#/components/schemas/RewindFilters"
      responses:
        "200":
          description: Updated.
          content:
            application/json:
              schema:
                type: object
                required:
                  - rewind_segment
                properties:
                  rewind_segment:
                    $ref: "#/components/schemas/RewindSegment"
        "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/ApiKeyRateLimited"
  /v1/rewind-segments/{id}/retire:
    post:
      operationId: retireRewindSegment
      tags:
        - rewind-segments
      summary: Retire a segment (terminal)
      description: >
        Logical retirement: the row stays (and its audit trail with it), it
        leaves

        the list, and it can no longer be used or edited. Nothing is ever
        deleted.

        Retiring again is `404`.
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: Retired.
          content:
            application/json:
              schema:
                type: object
                required:
                  - rewind_segment
                properties:
                  rewind_segment:
                    $ref: "#/components/schemas/RewindSegment"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/transfer-destinations:
    get:
      operationId: listTransferDestinations
      tags:
        - transfer-destinations
      summary: The tenant's current external transfer destinations
      description: |
        The registered numbers a blind transfer may leave towards, oldest
        registration first. Retired destinations are not listed. Readable by a
        `dd_` key and by any dashboard login; a widget token (`ddw_`) is `403
        forbidden`.
      responses:
        "200":
          description: The current registrations.
          content:
            application/json:
              schema:
                type: object
                required:
                  - transfer_destinations
                properties:
                  transfer_destinations:
                    type: array
                    items:
                      $ref: "#/components/schemas/TransferDestination"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
    post:
      operationId: createTransferDestination
      tags:
        - transfer-destinations
      summary: Register an external transfer destination
      description: |
        Registers `number` (strict E.164) with a `label`. From then on a blind
        transfer may target it; it still passes the whole GATE_PRE_DIAL chain.
        The registration instant is also the anchor the Reassigned Numbers
        Database is queried with for this number, and registering schedules
        that query (a transfer to it blocks with `rnd.no_current_result.v1`
        until the answer arrives, in a cell with a real RND provider).

        **Tenant management only**: a `dd_` key or an `account_admin` login.
        A `member` login is `403 role_forbidden`; a widget token (`ddw_`) is
        `403 forbidden`. Who registered it is taken from the credential, never
        from the body. A number that is already a CURRENT registration of this
        tenant is `409 conflict`; retire it first to register it again.

        `line_type` is what the tenant declares the line to be, exactly as it
        declares it for a consumer it imports (same values, same default
        `unknown`). It is what the consent gate reads for a transfer to this
        number: `landline` lifts the prior-express-consent requirement, and
        nothing else — every other gate still runs. A blank or absent value is
        `unknown`. Change it later with `PATCH`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required:
                - number
                - label
              properties:
                number:
                  type: string
                  pattern: ^\+[1-9][0-9]{6,14}$
                  description: Strict E.164.
                label:
                  type: string
                  minLength: 1
                  maxLength: 120
                  description: What the number is, for the operator (trimmed; blank is refused).
                line_type:
                  $ref: "#/components/schemas/TransferDestinationLineType"
            example:
              number: "+13125550170"
              label: Legal department
              line_type: landline
      responses:
        "201":
          description: Registered.
          content:
            application/json:
              schema:
                type: object
                required:
                  - transfer_destination
                properties:
                  transfer_destination:
                    $ref: "#/components/schemas/TransferDestination"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: |
            `role_forbidden`: a dashboard login without the `account_admin`
            role; `forbidden`: a widget token or a suspended tenant.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: role_forbidden
                  message: a dashboard login needs the account_admin role to manage transfer
                    destinations
        "409":
          $ref: "#/components/responses/Conflict"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/transfer-destinations/{id}:
    patch:
      operationId: declareTransferDestinationLineType
      tags:
        - transfer-destinations
      summary: Declare the line type of a registered external transfer destination
      description: |
        Changes the `line_type` of the CURRENT registration `id`. The change is
        a new fact in the registration's append-only history, with who made
        it (taken from the credential) and when; the answer carries both as
        `line_type_declared_by` / `line_type_declared_at`. The registration
        itself does not move: same id, same `registered_at`, so the RND anchor
        of the number is unchanged. Declaring the value it already has writes
        nothing.

        The next transfer to the number reads the new value. `landline` lifts
        the consent gate's prior-express-consent requirement and nothing else;
        `unknown`, `mobile` and `voip` keep it.

        Only `line_type` is accepted (the number and the label are the
        registration: retire and register again to change them); any other key
        is `400`. A value outside the list is `400`. An id that is not a
        current registration of this tenant (unknown, retired, or another
        tenant's) is `404`. Same roles as the registration.
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required:
                - line_type
              properties:
                line_type:
                  $ref: "#/components/schemas/TransferDestinationLineType"
            example:
              line_type: landline
      responses:
        "200":
          description: The destination with its current line type.
          content:
            application/json:
              schema:
                type: object
                required:
                  - transfer_destination
                properties:
                  transfer_destination:
                    $ref: "#/components/schemas/TransferDestination"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: |
            `role_forbidden`: a dashboard login without the `account_admin`
            role; `forbidden`: a widget token or a suspended tenant.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
    delete:
      operationId: retireTransferDestination
      tags:
        - transfer-destinations
      summary: Retire an external transfer destination
      description: |
        Retires the CURRENT registration `id`: a blind transfer to that number
        is refused again with `transfer.destination_unregistered.v1`. The
        history is append-only — the retirement is a new fact, and the answer
        is the destination that was retired. An id that is not a current
        registration of this tenant (unknown, already retired, or another
        tenant's) is `404`. Same roles as the registration.
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: Retired.
          content:
            application/json:
              schema:
                type: object
                required:
                  - transfer_destination
                properties:
                  transfer_destination:
                    $ref: "#/components/schemas/TransferDestination"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: |
            `role_forbidden`: a dashboard login without the `account_admin`
            role; `forbidden`: a widget token or a suspended tenant.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/auth/sso/{tenant_id}/metadata:
    get:
      operationId: getSsoServiceProviderMetadata
      tags:
        - sso
      summary: SAML service-provider metadata of a tenant (for its IdP admin)
      description: >
        The SAML 2.0 SP metadata the tenant's IdP admin pastes into the identity

        provider: this tenant's `entityID`, its Assertion Consumer Service

        (HTTP-POST), its SingleLogoutService (HTTP-POST and HTTP-Redirect) and
        the

        NameID format (`emailAddress`). PUBLIC

        (`security: []`) and built from the `tenant_id` in the path alone — it

        reads nothing per tenant, so the metadata of a tenant that does not

        exist is byte-for-byte that of one that does (no tenant-existence

        oracle). Every tenant is a DISTINCT SP: an assertion issued for one

        tenant's SP never validates for another's. `404` while the cell has no

        public SP base URL configured (`SSO_SP_BASE_URL`), and for a

        `tenant_id` that is not a UUID.
      security: []
      parameters:
        - name: tenant_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: The SP metadata document.
          content:
            application/samlmetadata+xml:
              schema:
                type: string
        "404":
          $ref: "#/components/responses/NotFound"
  /v1/auth/sso/{tenant_id}/slo:
    get:
      operationId: getSsoSingleLogout
      tags:
        - sso
      summary: SAML Single Logout, from the IdP (HTTP-Redirect binding)
      description: >
        Single Logout (core#1581, ADR #210). Where the tenant's IdP sends a

        `LogoutRequest` through the browser — HTTP-Redirect (`GET`,
        `SAMLRequest`

        in the query, the signature in `Signature`/`SigAlg`, verified over the
        RAW

        query) or HTTP-POST (`SAMLRequest` form field, enveloped signature).
        When

        the request is signed by THIS tenant's IdP, every live `ddu_` session of

        the person its `NameID` names is revoked.


        Validated as strictly as the ACS: signature against the pinned

        certificates of this tenant only, `Issuer`, `Destination` (this
        endpoint),

        `IssueInstant` within a few minutes, `NotOnOrAfter`, and a
        `LogoutRequest`

        `ID` never seen before (replay). Fail-closed: ANY doubt — unknown
        tenant,

        SSO off, bad signature, foreign issuer, expired, repeated — is ONE

        identical `400` and nothing is revoked; the reason is only in the server

        log. A `NameID` that names nobody is the same `303` as one that does (no

        user enumeration). It never answers `500` for a hostile document.


        Success is `303`: to the IdP's `slo_url` carrying a `LogoutResponse`

        (unsigned; the `RelayState` echoed when printable and at most 80 bytes)

        or, when the IdP published none, to the dashboard. A `SAMLResponse` (the

        IdP answering a logout core started) changes nothing and is `303` to the

        dashboard. `404` while the cell has no public SP base URL.


        IdP-INITIATED LOGIN (an unsolicited `Response` at the ACS) is

        deliberately NOT supported: it would reopen login-CSRF and assertion

        replay, which the two-step exchange closes.
      security: []
      parameters:
        - name: tenant_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
        - name: SAMLRequest
          in: query
          required: false
          schema:
            type: string
            description: Base64 of the DEFLATE-compressed `samlp:LogoutRequest`.
        - name: RelayState
          in: query
          required: false
          schema:
            type: string
        - name: SigAlg
          in: query
          required: false
          schema:
            type: string
            description: RSA-SHA256, RSA-SHA384 or RSA-SHA512; SHA-1 is refused.
        - name: Signature
          in: query
          required: false
          schema:
            type: string
            description: Base64 of the signature over the raw query.
      responses:
        "303":
          description: |
            Done (or nothing to do); `Location` is the IdP's `slo_url` with the
            `LogoutResponse`, or the dashboard.
          headers:
            Location:
              schema:
                type: string
                format: uri
        "400":
          description: The logout request was rejected (one answer for every reason).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/LoginRateLimited"
    post:
      operationId: postSsoSingleLogout
      tags:
        - sso
      summary: SAML Single Logout, from the IdP (HTTP-POST binding)
      description: >
        Single Logout (core#1581, ADR #210). Where the tenant's IdP sends a

        `LogoutRequest` through the browser — HTTP-Redirect (`GET`,
        `SAMLRequest`

        in the query, the signature in `Signature`/`SigAlg`, verified over the
        RAW

        query) or HTTP-POST (`SAMLRequest` form field, enveloped signature).
        When

        the request is signed by THIS tenant's IdP, every live `ddu_` session of

        the person its `NameID` names is revoked.


        Validated as strictly as the ACS: signature against the pinned

        certificates of this tenant only, `Issuer`, `Destination` (this
        endpoint),

        `IssueInstant` within a few minutes, `NotOnOrAfter`, and a
        `LogoutRequest`

        `ID` never seen before (replay). Fail-closed: ANY doubt — unknown
        tenant,

        SSO off, bad signature, foreign issuer, expired, repeated — is ONE

        identical `400` and nothing is revoked; the reason is only in the server

        log. A `NameID` that names nobody is the same `303` as one that does (no

        user enumeration). It never answers `500` for a hostile document.


        Success is `303`: to the IdP's `slo_url` carrying a `LogoutResponse`

        (unsigned; the `RelayState` echoed when printable and at most 80 bytes)

        or, when the IdP published none, to the dashboard. A `SAMLResponse` (the

        IdP answering a logout core started) changes nothing and is `303` to the

        dashboard. `404` while the cell has no public SP base URL.


        IdP-INITIATED LOGIN (an unsolicited `Response` at the ACS) is

        deliberately NOT supported: it would reopen login-CSRF and assertion

        replay, which the two-step exchange closes.
      security: []
      parameters:
        - name: tenant_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              properties:
                SAMLRequest:
                  type: string
                  description: Base64 of the signed `samlp:LogoutRequest`.
                SAMLResponse:
                  type: string
                  description: Base64 of a `samlp:LogoutResponse`; ignored.
                RelayState:
                  type: string
      responses:
        "303":
          description: |
            Done (or nothing to do); `Location` is the IdP's `slo_url` with the
            `LogoutResponse`, or the dashboard.
          headers:
            Location:
              schema:
                type: string
                format: uri
        "400":
          description: The logout request was rejected (one answer for every reason).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/LoginRateLimited"
  /v1/auth/sso/start:
    post:
      operationId: startSsoLogin
      tags:
        - sso
      summary: Start a SAML SSO login (SP-initiated)
      description: >
        Returns the URL of the tenant's identity provider carrying a fresh

        `AuthnRequest` (HTTP-Redirect binding, unsigned), plus a
        `browser_secret`.

        It writes NOTHING: the login state travels in the `RelayState`, a

        compact HMAC-signed token (request id, expiry, IdP configuration

        version; the tenant is authenticated inside the MAC and the browser

        secret is derived, never carried) that the ACS verifies. The dashboard

        sends the browser to `redirect_url`

        and keeps `browser_secret` in the SAME browser (`sessionStorage`): it is

        the only thing that can redeem the login later (`POST

        /v1/auth/sso/exchange`), so a login a third party starts cannot be

        finished in someone else's browser. The login lives 5 minutes and is

        single-use (spent when a validly signed `Response` for it reaches the

        ACS). PUBLIC; `404` is ONE answer for a tenant that does not exist,

        a tenant whose IdP is not ENABLED and a cell without `SSO_SP_BASE_URL`.
        The

        per-origin brake is OBSERVATION-ONLY by default (anonymous and
        stateless,

        and behind the dashboard proxy the origin collapses, so an enforced cap

        would deny every tenant at once); a cell that trusts its origin axis

        promotes it with `BRAKE_ENFORCE_BUCKETS=sso_start`.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - tenant_id
              properties:
                tenant_id:
                  type: string
                  format: uuid
      responses:
        "200":
          description: "The IdP URL and the secret of this browser (`Cache-Control:
            no-store`)."
          content:
            application/json:
              schema:
                type: object
                required:
                  - redirect_url
                  - browser_secret
                  - expires_at
                properties:
                  redirect_url:
                    type: string
                    format: uri
                  browser_secret:
                    type: string
                    description: Shown exactly once; core stores nothing at start and re-derives it
                      at the ACS to keep only its hash.
                  expires_at:
                    type: string
                    format: date-time
        "400":
          $ref: "#/components/responses/BadRequest"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          description: >
            Only in a cell that PROMOTED the start brake
            (`BRAKE_ENFORCE_BUCKETS`

            lists `sso_start`): the per-ORIGIN brake refused this call

            (`rate_limited`; 300 per fixed window by default,

            `SSO_START_MAX_PER_WINDOW`). Never in the default observation-only

            mode. Keyed by the

            caller's origin, never by `tenant_id`. `Retry-After` is the time
            left

            in the window.
          headers:
            Retry-After:
              $ref: "#/components/headers/RetryAfter"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
  /v1/auth/sso/{tenant_id}/acs:
    post:
      operationId: postSsoAssertionConsumer
      tags:
        - sso
      summary: SAML Assertion Consumer Service (HTTP-POST binding)
      description: >
        Where the IdP's browser POSTs the `SAMLResponse` (form-encoded, with the

        `RelayState` returned by `POST /v1/auth/sso/start`). It never answers

        with a session: it is ALWAYS a `303` back to the dashboard callback

        (`/sso/callback`), with `#code=<one-shot code>` in the URL FRAGMENT on

        success (single use, valid 2 minutes, redeemed at `POST

        /v1/auth/sso/exchange`) or `#error=sso_failed` on ANY rejection — the

        precise reason is only in the server log, never in the answer.


        The response is validated fail-closed: the signature (assertion and/or

        response, per the tenant's configuration) against the pinned

        certificates of THIS tenant only (the `KeyInfo` of the document is

        ignored), the signed element is the one whose claims are read (no XML

        signature wrapping), a single assertion, `Issuer`, `Audience` (this

        tenant's SP), `Recipient`/`Destination` (this ACS), `InResponseTo`

        (the request core issued, verified from the signed `RelayState` and
        spent

        only when the IdP signature validates: a forged `Response` does not burn

        the login),

        `NotBefore`/`NotOnOrAfter` with 60 s of skew, and an assertion `ID`

        never seen before (replay). The person must ALREADY exist (matched by

        the verified email; there is no just-in-time provisioning) and be

        active.
      security: []
      parameters:
        - name: tenant_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required:
                - SAMLResponse
                - RelayState
              properties:
                SAMLResponse:
                  type: string
                  description: Base64 of the `samlp:Response`.
                RelayState:
                  type: string
                  description: The signed token `POST /v1/auth/sso/start` put in the redirect
                    (base64url, 60 characters).
      responses:
        "303":
          description: |
            Redirect to the dashboard callback; `Location` carries `#code=...`
            or `#error=sso_failed` in its fragment.
          headers:
            Location:
              schema:
                type: string
                format: uri
        "404":
          $ref: "#/components/responses/NotFound"
  /v1/auth/sso/exchange:
    post:
      operationId: exchangeSsoLogin
      tags:
        - sso
      summary: Redeem an SSO login code for a session
      description: >
        Redeems the `code` the ACS handed to the dashboard together with the

        `browser_secret` `POST /v1/auth/sso/start` returned to this same

        browser, and mints the usual `ddu_` session (same body as `POST

        /v1/auth/login`). The code is single-use and dies on its FIRST

        presentation, whether the secret matched or not; unknown, expired,

        spent and wrong-browser are ONE identical `401`. An SSO login does not

        ask for the application's own second factor (the IdP is the authority),

        and works whether or not the tenant closed the password lane

        (`sso_required_enforced`). The tenant's login policy is asked here (the

        IdP's word is not the tenant's allowlist): a tenant that published a
        login policy (allowed addresses and

        hours per role, ADR 227) in mode `enforce` refuses a sign-in from an
        address

        outside it or at an hour outside it with ONE `403`
        `login_policy_denied`, the

        same for the address and the hour: which of them failed is told only by
        the

        tenant's sign-in trail (`GET /v1/auth/sign-in-events`). Asked AFTER the

        credential proved right and before anything is issued. With the policy
        off or

        absent the sign-in is the one of before.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - code
                - browser_secret
              properties:
                code:
                  type: string
                browser_secret:
                  type: string
      responses:
        "200":
          description: The session (`token` is the only time the plaintext is visible).
          content:
            application/json:
              schema:
                type: object
                required:
                  - token
                  - session
                  - user
                  - tenant
                properties:
                  token:
                    type: string
                  session:
                    $ref: "#/components/schemas/UserSession"
                  user:
                    $ref: "#/components/schemas/User"
                  tenant:
                    $ref: "#/components/schemas/Tenant"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          description: Unknown, expired, already-used or wrong-browser code.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/LoginRateLimited"
  /v1/sso/idp-config:
    get:
      operationId: getSsoIdpConfig
      tags:
        - sso
      summary: The tenant's SAML identity-provider configuration
      description: |
        The tenant's IdP configuration (`sso_idp_config`, `null` when none is
        loaded) and the service-provider URLs to give the IdP
        (`service_provider`, `null` while the cell has no SP base URL). The
        certificates are shown as SHA-256 fingerprint + validity window, never
        as PEM.

        **`account_admin` dashboard login only** (see `403`).
      responses:
        "200":
          description: The configuration and the SP URLs.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SsoIdpConfigEnvelope"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: |
            `role_forbidden`: anything but a dashboard login (`ddu_`) whose
            person has the `account_admin` role — a `dd_` API key and a
            `member` login included; `forbidden`: a widget token (`ddw_`) or a
            suspended tenant.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: role_forbidden
                  message: SSO configuration takes a dashboard login with the account_admin role
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
    put:
      operationId: putSsoIdpConfig
      tags:
        - sso
      summary: Load or replace the tenant's SAML identity-provider configuration
      description: |
        Full-body upsert: the IdP's `entityID`, its HTTP-Redirect SSO URL
        (`https` only) and one or two X.509 signing certificates (two to rotate
        without cutting access). Each certificate must be exactly one PEM block,
        RSA of at least 2048 bits, and valid now. A certificate is trusted by
        its SHA-256 fingerprint (the chain is not validated). At least one of
        `require_signed_assertion` / `require_signed_response` must be true:
        there is no configuration that accepts an unsigned assertion.

        The configuration is stored DISABLED unless `enabled: true` is sent;
        loading it does not open the door. Every effective change writes an
        audit fact, in the same transaction, with the person who made it. A
        replacement identical to what is stored writes nothing.

        A value that fails validation (a non-`https` URL, a certificate that
        is weak, expired or not exactly one PEM block, a repeated certificate)
        is `400` naming the field.

        **`account_admin` dashboard login only** (see `403`).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required:
                - idp_entity_id
                - sso_url
                - certificates
              properties:
                idp_entity_id:
                  type: string
                  minLength: 1
                  maxLength: 1024
                sso_url:
                  type: string
                  maxLength: 2048
                  pattern: ^https://\S+$
                slo_url:
                  type:
                    - string
                    - "null"
                  maxLength: 2048
                  pattern: ^https://\S+$
                  description: The IdP's SingleLogoutService (https); absent or null means the IdP
                    publishes none.
                certificates:
                  type: array
                  minItems: 1
                  maxItems: 2
                  items:
                    type: string
                    description: One PEM-encoded X.509 certificate.
                require_signed_assertion:
                  type: boolean
                  default: true
                require_signed_response:
                  type: boolean
                  default: false
                email_attribute:
                  type:
                    - string
                    - "null"
                  maxLength: 256
                  description: SAML attribute that carries the email; absent or null means the
                    NameID.
                enabled:
                  type: boolean
                  default: false
      responses:
        "200":
          description: Stored.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SsoIdpConfigEnvelope"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: |
            `role_forbidden`: anything but a dashboard login (`ddu_`) whose
            person has the `account_admin` role — a `dd_` API key and a
            `member` login included; `forbidden`: a widget token (`ddw_`) or a
            suspended tenant.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: role_forbidden
                  message: SSO configuration takes a dashboard login with the account_admin role
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
    patch:
      operationId: setSsoIdpConfigEnabled
      tags:
        - sso
      summary: Enable or disable the tenant's SAML configuration
      description: |
        Only `enabled` is accepted. Enabling re-checks that every stored
        certificate is still valid now (`422` otherwise). `404` when the tenant
        has no configuration. Setting the value it already has writes nothing.

        **`account_admin` dashboard login only** (see `403`).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required:
                - enabled
              properties:
                enabled:
                  type: boolean
      responses:
        "200":
          description: The configuration with its new state.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SsoIdpConfigEnvelope"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: |
            `role_forbidden`: anything but a dashboard login (`ddu_`) whose
            person has the `account_admin` role — a `dd_` API key and a
            `member` login included; `forbidden`: a widget token (`ddw_`) or a
            suspended tenant.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: role_forbidden
                  message: SSO configuration takes a dashboard login with the account_admin role
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/Unprocessable"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
    delete:
      operationId: deleteSsoIdpConfig
      tags:
        - sso
      summary: Remove the tenant's SAML configuration
      description: |
        Removes the configuration; its audit history stays. The answer is the
        configuration that was removed. `404` when there is none.

        **`account_admin` dashboard login only** (see `403`).
      responses:
        "200":
          description: Removed.
          content:
            application/json:
              schema:
                type: object
                required:
                  - sso_idp_config
                properties:
                  sso_idp_config:
                    $ref: "#/components/schemas/SsoIdpConfig"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: |
            `role_forbidden`: anything but a dashboard login (`ddu_`) whose
            person has the `account_admin` role — a `dd_` API key and a
            `member` login included; `forbidden`: a widget token (`ddw_`) or a
            suspended tenant.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: role_forbidden
                  message: SSO configuration takes a dashboard login with the account_admin role
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/sso/idp-config/events:
    get:
      operationId: listSsoIdpConfigEvents
      tags:
        - sso
      summary: Audit trail of the tenant's SAML configuration
      description: |
        The append-only facts (created, updated, enabled, disabled, deleted),
        newest first, at most 100. Each carries who did it and a snapshot with
        certificate fingerprints (never PEM).

        **`account_admin` dashboard login only** (see `403`).
      responses:
        "200":
          description: The facts.
          content:
            application/json:
              schema:
                type: object
                required:
                  - events
                properties:
                  events:
                    type: array
                    items:
                      $ref: "#/components/schemas/SsoIdpConfigEvent"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: |
            `role_forbidden`: anything but a dashboard login (`ddu_`) whose
            person has the `account_admin` role — a `dd_` API key and a
            `member` login included; `forbidden`: a widget token (`ddw_`) or a
            suspended tenant.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: role_forbidden
                  message: SSO configuration takes a dashboard login with the account_admin role
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/transfer/targets:
    get:
      operationId: listTransferTargets
      tags:
        - transfer-destinations
      summary: Where a live call can be transferred, as a picker needs it
      description: |
        The ONE read a transfer picker needs, and the only one of its kind a
        widget token (`ddw_`) may reach: the roster (`GET /v1/agents`) and the
        registry (`GET /v1/transfer-destinations`) stay `403` for it, because
        they answer more than a picker paints — emails, roles, credential
        state, the E.164 — and the widget lives inside third-party CRMs.
        Read-only, scoped to the caller's tenant. Three classes, each item
        carrying only what the picker shows and the value its transfer verb
        takes as `destination`:

        - `agents`: ACTIVE agents with a `sip_extension` — send the `id` (or
          the extension). `presence` is the seat state NOW and is informative:
          whether the seat is free is the transfer's own answer (`422
          destination_unavailable`). A widget token does not see its own agent.
        - `extensions`: roster extensions with no agent behind them — send the
          `sip_extension`. **Always empty today**: the roster has no extension
          apart from its agents'. The class is in the answer so a picker does
          not change shape when one appears.
        - `external_destinations`: the tenant's CURRENT registered external
          destinations, `id` and `label` only — **never the number**. Send the
          `id` to `POST /v1/calls/{call_id}/transfer`; core resolves the E.164
          itself and the transfer passes every gate a transfer to that number
          passes. The attended transfer does not take them.

        **Fail-closed**: if the roster, the seat state or the registry cannot
        be read the answer is `503 transfer_targets_unavailable`, never an
        empty list that would read as "there is nowhere to transfer to".
      security:
        - tenantApiKey: []
        - userSessionToken: []
        - widgetSessionToken: []
      responses:
        "200":
          description: The three classes (any of them may be empty).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TransferTargets"
              example:
                agents:
                  - id: 2f1c9a2e-6b3d-4c1e-9a7f-0d4e5b6c7a81
                    name: Ana Ruiz
                    sip_extension: "1002"
                    presence: available
                extensions: []
                external_destinations:
                  - id: 7b0e1f3a-2c4d-4e5f-8a9b-1c2d3e4f5a6b
                    label: Legal department
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
        "503":
          description: |
            `transfer_targets_unavailable`: the roster, the seat state or the
            registry could not be read. Retry; an empty class is never used to
            say this.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: transfer_targets_unavailable
                  message: transfer targets could not be read, retry
  /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"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
        "503":
          description: The counters could not be read within the statement timeout
            (`report_timeout`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
  /v1/stats/contact-windows:
    get:
      operationId: statsContactWindows
      tags:
        - stats
      summary: Contact rate by the consumer's local hour, best window, phones per person
      description: >
        For every hour of the day (0-23) in the CONSUMER's local time: how many

        attempts were placed, how many reached a person (`answered_human`) and
        the

        rate. The hour is the consumer's, not the tenant's or UTC's: it uses the

        zone the quiet-hours gate froze into the attempt when it was placed (the

        number's area-code zones plus the contact's declared zone), summer time

        included. Derived on read from the append-only CDR; no counter is
        stored.


        An attempt is one original platform-placed outbound call. Inbound calls,

        external-transfer legs and rows the platform never placed are not
        attempts.

        A contact is an attempt with at least one `answered_human` outcome.


        An attempt whose local hour cannot be known is counted in `unknown`,
        never

        spread over the hours: `no_zone` (nothing frozen, or a zone the
        time-zone

        database does not know) and `ambiguous_zone` (an area code that spans

        several zones, or a declared zone that disagrees with it).
        `unknown.attempts`

        is the sum of both reasons.


        `best_window` is the two consecutive local hours with the highest
        contact

        rate, and it is named only when EACH of the two hours holds at least

        `min_attempts` attempts (tenant setting `contact_window_min_attempts`,

        default 50). Otherwise it is `null` and `best_window_reason` says why

        (`insufficient_attempts`, or `no_resolved_attempts` when no attempt had
        a

        known local hour); an hour is never invented from a handful of calls.
        Ties

        go to the earlier hour; the window does not wrap past midnight. Both
        hours

        must also lie inside the legal calling window `[08:00, 21:00)`, so a
        window

        is never recommended in a time the platform would refuse to dial; hours

        outside it still appear in `hours` but cannot anchor `best_window`.


        `weekdays` is the same contact rate on the consumer's local ISO weekday
        axis

        (1 = Monday ... 7 = Sunday). `applied` says whether the tenant's

        `best_time_ranking_enabled` knob is on (the dialer feed orders leads by
        this

        data), `source` the level that speaks for it (`campaign`, `portfolio` or

        `none`), and `weights` the weight table the dialer applies to

        each (weekday, local hour) cell inside the legal window while `applied`
        is true,

        with the level that spoke for that cell; when `applied` is false the
        dialer keeps

        the neutral order and `source`/`weights` show what it would apply with
        the knob on. `weights` always comes from the trailing 90 days

        and is not narrowed by `from`/`to`.


        `phones_per_person` is the mean number of distinct phone numbers per

        person (`consumer_ref`) among the tenant's active accounts (any state
        but

        `paid`, `settled` or `closed`). A person with an active account and no

        phone counts as 0. It is tenant-wide: `from`, `to` and `campaign_id` do
        not

        narrow it. `average` is `null` when there is no active person.


        `from`/`to` default to the trailing 30 days; a window wider than 90
        days,

        or with `from` not before `to`, is `422`. An unknown campaign (or
        another

        tenant's) is `404`, never an all-zero report.
      parameters:
        - $ref: "#/components/parameters/FromFilter"
        - $ref: "#/components/parameters/ToFilter"
        - $ref: "#/components/parameters/CampaignIdFilter"
      responses:
        "200":
          description: Hourly contact rates, the best window and phones per person.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ContactWindowsReport"
              example:
                from: 2026-09-04T00:00:00Z
                to: 2026-10-04T00:00:00Z
                campaign_id: null
                basis: consumer_local_hour
                min_attempts: 50
                hours:
                  - hour: 9
                    attempts: 120
                    contacts: 18
                    contact_rate: 0.15
                  - hour: 10
                    attempts: 140
                    contacts: 28
                    contact_rate: 0.2
                  - hour: 11
                    attempts: 0
                    contacts: 0
                    contact_rate: null
                weekdays:
                  - weekday: 1
                    attempts: 90
                    contacts: 15
                    contact_rate: 0.1667
                unknown:
                  attempts: 14
                  contacts: 1
                  no_zone: 9
                  ambiguous_zone: 5
                applied: true
                source: portfolio
                weights:
                  - weekday: 1
                    hour: 9
                    weight: 1.2
                    source: portfolio
                    attempts: 120
                    contacts: 18
                  - weekday: 1
                    hour: 10
                    weight: 1
                    source: none
                    attempts: 0
                    contacts: 0
                best_window:
                  start_hour: 9
                  end_hour: 11
                  attempts: 260
                  contacts: 46
                  contact_rate: 0.1769
                best_window_reason: null
                phones_per_person:
                  persons: 4
                  phones: 7
                  average: 1.75
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          description: The window is wider than 90 days, or `from` is not before `to`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: unprocessable
                  message: the window must not exceed 90 days
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/stats/promises:
    get:
      operationId: statsPromises
      tags:
        - stats
      summary: PTP pipeline summary, tenant totals and per campaign
      description: |
        Currencies never sum together; `kept_rate_30d` is `null` when nothing
        resolved in the window.

        `by_campaign` has one row per campaign with at least one promise,
        derived at read time through the attempt the promise was captured on
        (`promises.call_attempt_id -> call_attempts.campaign_id`); no counter
        is stored. A promise with no attempt, or whose attempt has no
        campaign, is grouped in the row with `campaign_id: null` — never
        dropped — so the rows always add up to the top-level totals.

        With `campaign_id` every figure narrows to that campaign. An unknown
        campaign (or another tenant's) is `404`, never an all-zero summary.

        **The kept rate is client-reported.** The platform does not learn on
        its own whether a consumer paid: a promise only becomes `kept` or
        `broken` when the client says so through `PATCH /v1/promises/{id}`.
        A client that never reports leaves its promises `pending` and the
        rate `null`.
      parameters:
        - $ref: "#/components/parameters/CampaignIdFilter"
      responses:
        "200":
          description: Pipeline summary.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PromisesPipelineSummary"
              example:
                campaign_id: null
                open:
                  count: 4
                  amount_cents:
                    USD: 50000
                    MXN: 120000
                resolved_30d:
                  kept: 3
                  broken: 1
                kept_rate_30d: 0.75
                window_days: 30
                by_campaign:
                  - campaign_id: 3f2c1b0a-9d8e-4c7b-a6f5-4e3d2c1b0a99
                    open:
                      count: 3
                      amount_cents:
                        USD: 50000
                        MXN: 0
                    resolved_30d:
                      kept: 3
                      broken: 1
                    kept_rate_30d: 0.75
                  - campaign_id: null
                    open:
                      count: 1
                      amount_cents:
                        USD: 0
                        MXN: 120000
                    resolved_30d:
                      kept: 0
                      broken: 0
                    kept_rate_30d: null
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /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

        optional (max 100; `campaign_id=` also accepted): without it the report

        covers EVERY campaign of the calling tenant, with one person-level

        total (an account in two campaigns counts once), so a tenant with more

        than 100 campaigns reads its whole penetration in one call. With the

        list the response is the same as ever.


        `granularity=day` adds `by_day`: one row per LOCAL day of the window and

        per campaign, the day cut in the IANA zone `tz` names, summer time

        included (`CET` is not a fixed +01:00). The grid is dense (a

        quiet day is a row of zeros) and is derived on read from the

        append-only CDR. With `granularity=day`, `tz` is REQUIRED — there is no

        tenant zone to default to — and a missing or non-IANA `tz` is `422`,

        never a silent UTC cut; `from` and `to` are required too and the window

        is capped at 31 days (`400` otherwise, the `/v1/reports/attempts`

        criterion). `totals` and `by_campaign` stay the range numbers: a

        person-level count does not add up across days. Without `granularity`

        (or with `total`) the response is unchanged and `tz` is not read.


        `window=7d|30d|90d|all` answers the same `totals` / `by_campaign` over a

        window RELATIVE to now, served from a short-lived in-memory snapshot

        derived from the append-only CDR (nothing is written when dialing). The

        response adds `window` and `as_of`, the instant the numbers were

        computed; a snapshot older than the server's `max_age` (default 60 s) is

        never served, the report is computed live instead. `window` is exclusive

        with `from` / `to` and with `granularity=day` (`422`); without it the

        response is unchanged.
      parameters:
        - name: campaign_ids
          in: query
          required: false
          description: Comma-separated campaign UUIDs (max 100). Omit it (or send it
            empty) for every campaign of the tenant.
          schema:
            type: string
          example: 3f2c1b0a-9d8e-4c7b-a6f5-4e3d2c1b0a99
        - $ref: "#/components/parameters/FromFilter"
        - $ref: "#/components/parameters/ToFilter"
        - name: window
          in: query
          required: false
          description: Relative window served from the derived snapshot. Exclusive with
            `from`/`to` and with `granularity=day`.
          schema:
            type: string
            enum:
              - 7d
              - 30d
              - 90d
              - all
        - name: granularity
          in: query
          required: false
          description: "`total` (default) or `day` (adds `by_day`; requires `tz`, `from`
            and `to`)."
          schema:
            type: string
            enum:
              - total
              - day
            default: total
        - name: tz
          in: query
          required: false
          description: IANA time zone the day is cut in. Required with `granularity=day`;
            ignored otherwise.
          schema:
            type: string
          example: America/Mexico_City
      responses:
        "200":
          description: Penetration totals + per-campaign rows (+ `by_day` with
            `granularity=day`).
          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
                  reached_pct: 25
                  effective_contact_pct: 25
                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
                    reached_pct: 25
                    effective_contact_pct: 25
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "422":
          description: "`granularity=day` without `tz`, or with a `tz` that is not an IANA
            time zone; or `window` that is unknown or combined with `from`/`to`
            or `granularity=day`."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: unprocessable
                  message: tz is required with granularity=day
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
        "503":
          description: "`stats_unavailable`: the shared computation failed twice or ran
            past its cap (core#2497). Retry after `Retry-After` seconds."
          headers:
            Retry-After:
              $ref: "#/components/headers/RetryAfter"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: stats_unavailable
                  message: the statistics could not be computed right now; retry shortly
  /v1/stats/abandonment:
    get:
      operationId: statsAbandonment
      tags:
        - stats
      summary: Durable abandonment report of every campaign, in one response
      description: >
        The same report `GET /v1/campaigns/{id}/abandonment` gives, for EVERY

        campaign of the calling tenant at once (the console used to ask once per

        campaign). Served from a short-lived in-memory snapshot derived from the

        append-only CDR (nothing is written when dialing): `as_of` is the
        instant

        the numbers were computed, and a snapshot older than the server's

        `max_age` (default 60 s) is never served, the report is computed live

        instead. The per-campaign route stays live and is the record; this one

        is the overview.
      responses:
        "200":
          description: One `AbandonmentReport` per campaign, ordered by campaign name.
          content:
            application/json:
              schema:
                type: object
                required:
                  - campaigns
                  - as_of
                properties:
                  as_of:
                    type: string
                    format: date-time
                  campaigns:
                    type: array
                    items:
                      $ref: "#/components/schemas/AbandonmentReport"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
        "503":
          description: "`stats_unavailable`: the shared computation failed twice or ran
            past its cap (core#2497). Retry after `Retry-After` seconds."
          headers:
            Retry-After:
              $ref: "#/components/headers/RetryAfter"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: stats_unavailable
                  message: the statistics could not be computed right now; retry shortly
  /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"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /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"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /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`.

        Carries the rate and the estimated cost, so it is read like
        `/v1/billing/*`: a

        `dd_` key or an `account_admin` login; a `member` login gets `403
        role_forbidden`,

        also on the CSV.
      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
                    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"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /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.

        Carries the segment rates and the amount, so it is read like
        `/v1/billing/*`: a

        `dd_` key or an `account_admin` login; a `member` login gets `403
        role_forbidden`,

        also on the CSV.
      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"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/skip-trace/lookups:
    post:
      operationId: skipTraceLookup
      tags:
        - skip-trace
      summary: One measured skip-trace lookup
      description: >
        Tenant scope (a `dd_` key or a dashboard login). Order of refusals, none
        of

        which costs anything: `503 not_configured` when the platform has no
        vendor

        configured (the vendor is never called), `409 duplicate_request` for a

        `request_key` already used by this tenant, `429 skip_trace_cap_exceeded`

        when the tenant's monthly cap (queries or USD) is reached — a HARD cut:
        the

        refused lookup is neither called nor measured. A vendor failure is

        `502 provider_error` and is not charged. The subject and the results are

        never stored. There is no invoice line yet: it waits for the A3 gate.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SkipTraceLookupRequest"
            example:
              request_key: lookup-2026-10-06-0001
              subject:
                name: Jane Doe
                address: 1 Main St, Springfield
      responses:
        "200":
          description: The vendor's matches and what the lookup cost.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SkipTraceLookupResult"
              example:
                results:
                  - match: fake
                cost_usd: "0.10"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "409":
          description: A `request_key` this tenant already used (`duplicate_request`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "422":
          $ref: "#/components/responses/Unprocessable"
        "429":
          description: The monthly cap is reached (`skip_trace_cap_exceeded`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "502":
          description: The vendor failed (`provider_error`); nothing was charged.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "503":
          description: No vendor is configured (`not_configured`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
  /v1/skip-trace/cap:
    get:
      operationId: getSkipTraceCap
      tags:
        - skip-trace
      summary: The tenant's monthly skip-trace limits and usage so far
      description: >
        `cap` limits are `null` when unset (no limit). `usage` is the UTC
        calendar

        month to date; failed lookups do not count.
      responses:
        "200":
          description: Limits and month-to-date usage.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SkipTraceCapState"
              example:
                cap:
                  max_queries_per_month: 100
                  max_usd_per_month: "25.0000"
                usage:
                  queries: 3
                  spent_usd: "0.30"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
    put:
      operationId: putSkipTraceCap
      tags:
        - skip-trace
      summary: Set or clear the tenant's monthly skip-trace limits
      description: >
        A `dd_` key or an `account_admin` login (otherwise `403`). Replaces both

        limits; an omitted or `null` limit is cleared. Invalid values are `422`
        and

        nothing is stored.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SkipTraceCap"
            example:
              max_queries_per_month: 100
              max_usd_per_month: "25"
      responses:
        "200":
          description: The stored limits.
          content:
            application/json:
              schema:
                type: object
                required:
                  - cap
                properties:
                  cap:
                    $ref: "#/components/schemas/SkipTraceCap"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "422":
          $ref: "#/components/responses/Unprocessable"
  /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"
                  held_usd: "25.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"
        "403":
          description: >
            `role_forbidden` — a dashboard login without the `account_admin`
            role (a `dd_` API key and an

            `account_admin` read). The same body whatever the request says,
            `format=csv` included. A widget or

            other non-tenant scope gets `forbidden`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: role_forbidden
                  message: a dashboard login needs the account_admin role to read billing account
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/billing/tier:
    get:
      operationId: billingTier
      tags:
        - billing
      summary: The tier in force and the pending tier change, if any
      description: |
        The read that makes the right-vs-price gap VISIBLE. A customer who
        presses "upgrade plan", sees the new tier's capabilities and does NOT
        see the invoice move will conclude the upgrade failed — this endpoint
        is what lets the UI say "you asked for scale; it starts billing on
        2026-10-01; until then you are on growth at $69".

        `tier` is what the tenant has RIGHT NOW: product entitlements read the
        live tier, so this is the one that governs what the account can do.

        `pending_change` is `null` when there is nothing pending, and that is a
        real answer rather than a hole — a `404` would make "you have not asked
        for anything" indistinguishable from "your token points at nothing"
        (same criterion as `/v1/billing/account`). It is DERIVED from the open
        period's frozen tier and the live one, never from a separate record
        that could drift out of step with either.

        Both prices are strings: a `numeric` serialised as a JSON float loses
        cents, and this is the figure the dashboard shows next to "what you pay
        this month".
      responses:
        "200":
          description: The tier in force, plus the pending change or `null`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BillingTier"
              example:
                tenant_id: 0d4f4f9e-1f2a-4b53-9d3c-8a5e2f7b1c10
                tier: scale
                pending_change:
                  requested_tier: scale
                  requested_price_usd: "119.0000"
                  billing_tier: growth
                  billing_price_usd: "69.0000"
                  period_start: 2026-09-01
                  period_end: 2026-09-30
                  effective_from: 2026-10-01
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >
            `role_forbidden` — a dashboard login without the `account_admin`
            role (a `dd_` API key and an

            `account_admin` read). The same body whatever the request says,
            `format=csv` included. A widget or

            other non-tenant scope gets `forbidden`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: role_forbidden
                  message: a dashboard login needs the account_admin role to read billing tier
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/billing/tier/upgrade:
    post:
      operationId: billingTierUpgrade
      tags:
        - billing
      summary: Self-service tier UPGRADE — entitlements now, price at the next period
        close
      description: >
        Moves the tenant UP a tier with its own bearer. Two things happen, and

        they happen at different times on purpose:

          * **entitlements are immediate** — the live tier moves, so the account
            can use the new tier's capabilities from this minute;
          * **the price waits for the next period close** — the open period was
            already frozen at the tier it STARTED on, so this month's invoice
            does not move, not even by a cent.

        That split is the founder's decision (`macro-tasks#158`, 2026-09-07):

        a plan change binds "when the month of the current plan has already

        closed", and intra-month excess is billed through the overage lines

        that already exist rather than through a mid-month re-price. There is

        therefore **no proration charge and no new invoice line** — see ADR
        #130.


        Upgrades ONLY. The destination must be in the assignable vocabulary, be

        on sale in the catalog, and cost strictly MORE than the tier in force.

        A downgrade takes effect at the next period boundary with no refund and

        is not served here: it answers `422 not_an_upgrade`, and so does a

        repeated call (the tenant is already on the destination).


        The route takes NO tenant identifier — it operates on the bearer's

        tenant and only on it, so this surface cannot move another tenant's

        tier.


        **Who may call it** (core#1098, ADR #143). The tenant's own `dd_` API

        key, or a dashboard login whose user is `account_admin` — the same rule

        as `GET /v1/billing/tiers`. A `member` login is `403 role_forbidden`

        and nothing is written; the role is checked BEFORE the body, so a

        `member` gets that 403 whatever it sends (never the `400` / `422`

        below). A widget token (`ddw_`) is `403 forbidden`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - tier
              properties:
                tier:
                  type: string
                  description: The destination tier. Must be on sale and cost strictly more than
                    the current one.
                  example: scale
      responses:
        "200":
          description: |
            The tier moved. `pending_change` carries what was asked for, what
            the open period keeps billing at, and the date the new price starts.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BillingTierUpgrade"
              example:
                tenant:
                  id: 0d4f4f9e-1f2a-4b53-9d3c-8a5e2f7b1c10
                  name: Acme Collections
                  status: active
                  tier: scale
                tier: scale
                pending_change:
                  requested_tier: scale
                  requested_price_usd: "119.0000"
                  billing_tier: growth
                  billing_price_usd: "69.0000"
                  period_start: 2026-09-01
                  period_end: 2026-09-30
                  effective_from: 2026-10-01
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: |
            `role_forbidden` — a dashboard login (`ddu_`) whose user is not
            `account_admin`; the tier does not move. `forbidden` — a widget
            token (`ddw_`), or a suspended tenant.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: role_forbidden
                  message: a dashboard login needs the account_admin role to change the tier
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          description: |
            `not_an_upgrade` (a downgrade, a lateral move at the same price, or
            a repeated call), `unknown_tier` (not in the assignable vocabulary),
            `tier_not_sellable` (in the catalog but retired / unpriced), or
            `tier_not_in_catalog` (the tier currently in force cannot be priced,
            so there is nothing to compare against — fail-closed).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: not_an_upgrade
                  message: tier must cost strictly more than the current one; downgrades take
                    effect at the next period and are not served here
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/billing/tiers:
    get:
      operationId: billingTiers
      tags:
        - billing
      summary: The tiers on sale, with their catalog price — what an upgrade would
        cost BEFORE asking for it
      description: |
        The catalog a tenant can buy from, read BEFORE `POST
        /v1/billing/tier/upgrade`: every tier on sale with its catalog price,
        its currency and its entitlements, which one is the tenant's and which
        ones are an upgrade from it. It exists so that the confirmation in front
        of an upgrade shows a price served by core — the same catalog the
        invoice is priced from — instead of one written into a client.

        **What is on sale.** For each tier key, the NEWEST catalog version,
        kept only when that version is sellable AND priced (a retired tier is
        dropped even though older versions exist immutably), and only when the
        key is in the vocabulary a tenant may be moved to. Sorted by price,
        ascending. No tier that is not on sale is ever listed.

        **`upgrade` is the upgrade rule itself, evaluated without writing**: the
        tier is on sale, assignable, and costs strictly more than
        `current_price_usd`. A tier flagged `upgrade: true` is one the POST
        accepts; `false` is one it answers with `422`. When the tier in force
        has no catalog entry `current_price_usd` is `null` and nothing is an
        upgrade (the POST answers `422 tier_not_in_catalog`).

        **Whose price.** Tier prices are platform-wide: there is no per-tenant
        tier price in the catalog, so no other tenant's price can appear here.
        A design-partner discount (ADR #111) applies to the voice-AI add-on
        line, never to the tier price, so the price listed for a destination
        tier is the one this tenant starts paying for it at the next period.
        What the OPEN period bills at is `GET /v1/billing/tier`: it can differ
        from the list price of the tier in force, because the open period is
        frozen at the price it started on.

        **Who may read it.** The tenant's own `dd_` API key, or a dashboard
        login whose user is `account_admin`. A `member` login is `403
        role_forbidden`; a widget token (`ddw_`) is `403 forbidden`. The route
        takes no tenant identifier.

        **The price is per seat, per month** — the unit the `seats` line of
        the invoice multiplies by the billable seats. Money is a string so
        cents survive JSON.
      responses:
        "200":
          description: The tier in force, its catalog price, and the tiers on sale.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BillingTierCatalog"
              example:
                tenant_id: 0d4f4f9e-1f2a-4b53-9d3c-8a5e2f7b1c10
                tier: growth
                current_price_usd: "69.0000"
                tiers:
                  - tier: starter
                    label: Starter
                    version: 1
                    monthly_price_usd: "25.0000"
                    currency: USD
                    effective_from: 2026-07-07
                    entitlements:
                      max_seats: 5
                      max_concurrent_channels: null
                      included_minutes: "500"
                      included_sms_segments: 200
                      included_ai_minutes: null
                      included_dids: 1
                    current: false
                    upgrade: false
                  - tier: growth
                    label: Growth
                    version: 1
                    monthly_price_usd: "69.0000"
                    currency: USD
                    effective_from: 2026-07-07
                    entitlements:
                      max_seats: 25
                      max_concurrent_channels: null
                      included_minutes: "1500"
                      included_sms_segments: 500
                      included_ai_minutes: "100"
                      included_dids: 2
                    current: true
                    upgrade: false
                  - tier: scale
                    label: Scale
                    version: 1
                    monthly_price_usd: "119.0000"
                    currency: USD
                    effective_from: 2026-07-07
                    entitlements:
                      max_seats: 100
                      max_concurrent_channels: null
                      included_minutes: "2500"
                      included_sms_segments: 1500
                      included_ai_minutes: "150"
                      included_dids: 3
                    current: false
                    upgrade: true
                  - tier: enterprise
                    label: Enterprise
                    version: 1
                    monthly_price_usd: "199.0000"
                    currency: USD
                    effective_from: 2026-07-07
                    entitlements:
                      max_seats: null
                      max_concurrent_channels: null
                      included_minutes: "4000"
                      included_sms_segments: 2000
                      included_ai_minutes: "300"
                      included_dids: 5
                    current: false
                    upgrade: true
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /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"
        "403":
          description: >
            `role_forbidden` — a dashboard login without the `account_admin`
            role (a `dd_` API key and an

            `account_admin` read). The same body whatever the request says,
            `format=csv` included. A widget or

            other non-tenant scope gets `forbidden`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: role_forbidden
                  message: a dashboard login needs the account_admin role to read billing alerts
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /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, 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`).

        The seat billing floor is PER PLAN and a `live` `seats` line publishes
        the one

        it applies as `seat_minimum`: 1 on `starter` and `growth`, 3 on `scale`,
        25 on

        `enterprise` (so an `enterprise` tenant with 10 occupied seats bills

        `billable_seats: 25`). A `ledger_snapshot` `seats` line (a closed month)

        carries `seat_minimum: null`, like `occupied_seats: null`: the close
        stamps

        the quantity, not the floor it applied, and months closed before the
        floor

        became per-plan applied 3 on every 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.

        An intra-day window therefore sums WHOLE UTC days on those lines, and

        the response says so: `period.day_ledger` carries the closed day range

        they summed next to the `from`/`to` that were asked for (core#1383;

        the CSV carries it as `day_ledger_from_day`/`day_ledger_to_day`/

        `day_ledger_kinds` metadata rows).

        `?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
                  day_ledger:
                    from_day: 2026-06-05
                    to_day: 2026-07-04
                    kinds:
                      - house_minutes
                      - inbound_minutes
                      - did_rent
                      - sms
                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
                    minute_rate_usd: 0.25
                    metered_usd: 225
                    monthly_minimum_usd: 200
                    minimum_applied: false
                    amount_usd: 225
                total_usd: 870
            text/csv:
              schema:
                type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >
            `role_forbidden` — a dashboard login without the `account_admin`
            role (a `dd_` API key and an

            `account_admin` read). The same body whatever the request says,
            `format=csv` included. A widget or

            other non-tenant scope gets `forbidden`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: role_forbidden
                  message: a dashboard login needs the account_admin role to read billing summary
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/Unprocessable"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/billing/cost:
    get:
      operationId: billingCost
      tags:
        - billing
      summary: Gross ledger cost by campaign, carrier or agent over a bounded window
        (NOT the invoice)
      description: >
        "Which campaign / carrier / agent is spending right now": the usage

        LEDGER (`billable_events`) summed over `[from, to)` and grouped by ONE

        dimension per request (ADR #218, core#1647). The days already closed are

        read from the daily rollup (ADR #232, core#1751); only the two most

        recent UTC days (today and yesterday) are summed directly from the

        ledger joined to the CDR. `sources` says how many days of the window

        came from each place.


        **Behind a flag.** The route answers only for tenants with

        `billing_cost_enabled` ON, which is OFF by default: with it OFF every

        caller, of any role, gets the same `404 not_found`, as if the route did

        not exist. Nothing is deleted or hidden when it is OFF.


        **It is the gross ledger sum, not the bill.** The included allowance,

        the volume tier, the design-partner discount and the monthly minimum are

        applied on the invoice and are NOT split across groups; the

        `reconciliation` block puts `gross_total` next to the lines of

        `GET /v1/billing/summary` over the same window and lists what is not

        allocated (`not_allocated`). Whoever needs "what was billed" reads the

        invoice.


        **Nothing is dropped.** Every ledger row lands in exactly one group: the

        resolved campaign / carrier / agent, or the bucket with `key: null` and

        `unattributed: true` (no campaign, a CDR without a carrier, an agent not

        assigned, a call with no CDR row, an SMS). The groups of each currency

        add up to `gross_total`; if they ever did not, the answer is

        `500 cost_unreconciled`, never a partial number. Currencies are never

        mixed or converted: each group and each total carries its own.

        `billed_by` says who bills the telecom MINUTES of those calls:

        `platform` (house trunk), `tenant_carrier` (the customer's own carrier;

        the ledger amount is kept whole) or `null`.


        **Window.** `from` and `to` are MANDATORY RFC 3339 instants, `from <
        to`,

        at most 31 days apart (`400` otherwise), half-open like

        `/v1/billing/summary`: a `to` at exact UTC midnight belongs to the next

        window. The ledger is keyed by UTC day, so an intra-day window sums its

        WHOLE UTC days; `ledger_days` says which.


        **Who.** A `dd_` API key or a dashboard login with the `account_admin`

        role. Anyone else gets the same `403 role_forbidden` whatever they send

        and whatever the tenant holds: a refusal never says whether there is

        any cost to read. Tenant isolation is the database's (RLS): another

        tenant's rows are never read.


        **When it cannot answer.** If part of the window is neither closed in
        the

        rollup nor one of the two most recent days, or a closed day's rollup

        does not square with the ledger, the answer is `503 unavailable`: a

        partial number is never published. Retry later.
      parameters:
        - name: group_by
          in: query
          required: true
          description: The ONE dimension to group by.
          schema:
            type: string
            enum:
              - campaign
              - carrier
              - agent
        - name: from
          in: query
          required: true
          description: RFC 3339 lower bound, inclusive.
          schema:
            type: string
            format: date-time
        - name: to
          in: query
          required: true
          description: RFC 3339 upper bound, EXCLUSIVE; at most 31 days after `from`.
          schema:
            type: string
            format: date-time
      responses:
        "200":
          description: The groups, the gross total and the reconciliation block.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BillingCost"
              example:
                group_by: campaign
                from: 2026-10-01T00:00:00Z
                to: 2026-10-08T00:00:00Z
                ledger_days:
                  from: 2026-10-01
                  to: 2026-10-07
                sources:
                  rollup: 5
                  direct: 2
                  empty: 0
                groups:
                  - key: 6f1d2c3a-5b7e-4f0a-9c1d-2e3f4a5b6c7d
                    unattributed: false
                    currency: USD
                    billed_by: platform
                    amount: "41.2500"
                    events: 310
                  - key: null
                    unattributed: true
                    currency: USD
                    billed_by: null
                    amount: "8.7500"
                    events: 52
                gross_total:
                  - currency: USD
                    amount: "50.0000"
                    events: 362
                reconciliation:
                  basis: ledger_gross
                  groups_sum:
                    - currency: USD
                      amount: "50.0000"
                      events: 362
                  invoice:
                    status: available
                    currency: USD
                    total_usd: "870.0"
                    lines:
                      - kind: seats
                        amount_usd: "595"
                        gross_amount_usd: null
                      - kind: house_minutes
                        amount_usd: "0"
                        gross_amount_usd: "41.2500"
                  not_allocated:
                    - included_allowance
                    - volume_tier
                    - discount
                    - monthly_minimum
                  note: gross_total is the sum of the usage ledger, not the amount billed; the
                    included allowance, volume tier, discount and monthly
                    minimum are applied on the invoice and are not split across
                    groups
        "400":
          description: >
            `group_by` missing or not one of `campaign`, `carrier`, `agent`;
            `from` or `to`

            missing or not RFC 3339; `from` not before `to`; or a window over 31
            days.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: bad_request
                  message: date range must be <= 31 days
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >
            `role_forbidden` — a dashboard login without the `account_admin`
            role. The same body

            for every request of that login, so it reveals nothing about the
            tenant's cost.

            A widget or other non-tenant scope gets `forbidden`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: role_forbidden
                  message: a dashboard login needs the account_admin role to read billing cost
        "404":
          description: >
            `not_found` — the tenant has `billing_cost_enabled` OFF (the
            default). The same for

            every role and every parameter: the flag answers before anything
            else.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: not_found
                  message: resource not found for this tenant
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
        "500":
          description: "`cost_unreconciled` — the groups did not add up to the ledger
            total; nothing is published."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "503":
          description: >
            `unavailable` — part of the window is not closed in the rollup (and
            is not one of the

            two most recent days) or a closed day does not reconcile; nothing is
            published.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: unavailable
                  message: the cost of part of this window is not closed yet; nothing is
                    published, retry later
  /v1/billing/ledger:
    get:
      operationId: billingLedger
      tags:
        - billing
      summary: The tenant's wallet ledger, entry by entry, newest first
      description: >
        "Why did my balance drop": the rows of the tenant's wallet ledger

        (`balance_entries`), newest first and keyset-paginated (core#2573).
        Every

        movement says its `entry_type`, the signed per-bucket deltas, the UTC
        day

        it belongs to (`occurred_on`) and the two keys that join it to its

        origin: `ref_uuid` (the billable event id for a `debit`, the payment

        provider's idempotency key for a `topup`, `YYYY-MM` for a period grant)

        and `billable_event_id`. The row carries NO free text and no personal
        data.


        **Behind a flag.** The route answers only for tenants with

        `billing_ledger_enabled` ON, which is OFF by default: with it OFF every

        caller, of any role, gets the same `404 not_found`, as if the route did

        not exist. Nothing is deleted or hidden when it is OFF.


        **Pagination.** `limit` (default 50, at most 200) and `cursor` (the

        `next_cursor` of the previous page; null on the last page). Rows come

        ordered by `recorded_at` then `id`, both descending, so a page never

        repeats or skips a row that already existed when the first page was
        read.

        `from` / `to` bound `recorded_at` (`[from, to)`, both optional);

        `entry_type` keeps one kind of movement.


        **Who.** A `dd_` API key or a dashboard login with the `account_admin`

        role. Anyone else gets the same `403 role_forbidden` whatever they send.

        Tenant isolation is the database's (RLS): another tenant's rows are
        never

        read.
      parameters:
        - name: from
          in: query
          required: false
          description: RFC 3339 lower bound of `recorded_at`, inclusive.
          schema:
            type: string
            format: date-time
        - name: to
          in: query
          required: false
          description: RFC 3339 upper bound of `recorded_at`, EXCLUSIVE; must be after
            `from`.
          schema:
            type: string
            format: date-time
        - name: entry_type
          in: query
          required: false
          description: Keep one kind of movement.
          schema:
            type: string
            enum:
              - topup
              - debit
              - refund
              - adjustment
              - subscription_grant
              - grant_expiry
              - deposit_hold
              - deposit_release
              - deposit_apply
        - name: limit
          in: query
          required: false
          description: Page size, default 50, capped at 200.
          schema:
            type: integer
            minimum: 1
            default: 50
        - name: cursor
          in: query
          required: false
          description: The `next_cursor` of the previous page.
          schema:
            type: string
      responses:
        "200":
          description: A page of ledger entries.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BillingLedgerPage"
              example:
                entries:
                  - id: 0b7e9f2a-1c3d-4e5f-8a6b-7c8d9e0f1a2b
                    entry_type: debit
                    event_type: sms_segment
                    ref_uuid: 3f1d2c3a-5b7e-4f0a-9c1d-2e3f4a5b6c7d
                    billable_event_id: 3f1d2c3a-5b7e-4f0a-9c1d-2e3f4a5b6c7d
                    cash_delta: "-0.5000"
                    grant_delta: "0.0000"
                    deposit_delta: "0.0000"
                    occurred_on: 2026-10-24
                    recorded_at: 2026-10-24T15:04:05.123456Z
                next_cursor: null
        "400":
          description: >
            `from` or `to` not RFC 3339, `from` not before `to`, an unknown
            `entry_type`,

            or an invalid `limit` / `cursor`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: bad_request
                  message: invalid limit or cursor
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >
            `role_forbidden` — a dashboard login without the `account_admin`
            role. The same body

            for every request of that login. A widget or other non-tenant scope
            gets `forbidden`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: role_forbidden
                  message: a dashboard login needs the account_admin role to read the billing
                    ledger
        "404":
          description: >
            `not_found` — the tenant has `billing_ledger_enabled` OFF (the
            default). The same for

            every role and every parameter: the flag answers before anything
            else.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: not_found
                  message: resource not found for this tenant
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /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"
                    balance_settled_usd: "0.0000"
                    amount_due_usd: "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"
                        settled_from_balance_usd: null
                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
                      day_ledger:
                        from_day: 2026-06-01
                        to_day: 2026-06-30
                        kinds:
                          - house_minutes
                          - inbound_minutes
                          - did_rent
                          - sms
                    line_items:
                      - kind: seats
                        cadence: monthly
                        source: ledger_snapshot
                        occupied_seats: null
                        billable_seats: 5
                        seat_minimum: null
                        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"
        "403":
          description: >
            `role_forbidden` — a dashboard login without the `account_admin`
            role (a `dd_` API key and an

            `account_admin` read). The same body whatever the request says,
            `format=csv` included. A widget or

            other non-tenant scope gets `forbidden`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: role_forbidden
                  message: a dashboard login needs the account_admin role to read billing invoice
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/Unprocessable"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/reports/cdr:
    get:
      operationId: listCdrProtojson
      tags:
        - reports
      summary: CDR as canonical-protojson dialer.v1.CallDetailRecord
      description: >
        The rows of `GET /v1/call_attempts` — same filters (`campaign_id`,

        `debt_id`, `agent_id`, `disposition`, `from`, `to`), same `limit`

        (default 100, max 1000), same opaque `cursor`, same tenant isolation —

        each one projected onto the `dialer.v1.CallDetailRecord` message of the

        `contracts` repo and serialized as canonical protojson: lowerCamelCase

        field names, enums by name (`DISPOSITION_ANSWERED_HUMAN`), timestamps as

        RFC 3339 UTC, and every field at its proto3 default OMITTED (a call
        never

        answered has no `answeredAt`). The page itself follows the same rules:

        `nextCursor` is absent on the last page. Generated protojson parsers of

        `contracts` decode each element of `callDetailRecords`.


        One row is one record. The ledger is append-only, so an attempt is its

        original row plus the corrections appended to it, and each row is
        projected

        on its own, exactly as `/v1/call_attempts` pages them; collapsing an

        attempt into a single final record is not done here.


        Only what `/v1/call_attempts` already publishes is projected. Fields of
        the

        message with no source there (`direction`, `mediaPath`, `carrier`,

        `carrierTrunkId`, `attestation`, `costWeight`, `fsNode`, `sipEdgeNode`,

        `originationSource`, `transferredToCallId`) are omitted, and `cell` too.

        `callId` is the channel UUID (`call_uuid`), or the attempt id when the
        row

        never reached a channel. `consumerId` is the `consumer_ref`.


        `disposition` filters in the REST spelling (`answered_human`), as on

        `/v1/call_attempts`; the field of the record carries the enum name. The
        REST

        vocabulary is open and the contract enum is closed: a code the enum does

        not list (`abandoned`, `ai_conversation`, ...) leaves `disposition`
        omitted

        (`DISPOSITION_UNSPECIFIED`) and the raw code travels in

        `labels.disposition_code`.
      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 records.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CallDetailRecordPage"
              example:
                callDetailRecords:
                  - callId: 6f0a1b2c-3d4e-5f6a-7b8c-9d0e1f2a3b4c
                    tenantId: 0d4f4f9e-1f2a-4b53-9d3c-8a5e2f7b1c10
                    debtId: 5a4b3c2d-1e0f-4a9b-8c7d-6e5f4a3b2c1d
                    consumerId: cms-consumer-9001
                    campaignId: 3f2c1b0a-9d8e-4c7b-a6f5-4e3d2c1b0a99
                    fromNumber: "+13125550199"
                    toNumber: "+13125551001"
                    startedAt: 2026-07-05T15:00:00Z
                    answeredAt: 2026-07-05T15:00:07.250Z
                    endedAt: 2026-07-05T15:02:31Z
                    durationSeconds: 151
                    billsec: 144
                    disposition: DISPOSITION_ANSWERED_HUMAN
                    hangupCause: NORMAL_CLEARING
                    recordVersion: 1
                    complianceDecisionId: 2c1d0e9f-8a7b-6c5d-4e3f-2a1b0c9d8e7f
                nextCursor: MjAyNi0wNy0wNVQxNTowMDowMFp8OGM3ZDZlNWY
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/report-catalog:
    get:
      operationId: getReportCatalog
      tags:
        - reports
      summary: Report builder catalog (dimensions, metrics, limits)
      description: >
        The CLOSED vocabulary of the report builder (core#1964, phase 1): the

        dimensions and metrics a tenant can ask of ONE fact, `call_attempts`,
        with

        the limits `POST /v1/reports/run` enforces. Read-only; it describes the

        vocabulary and touches no tenant data. It keeps answering when the
        tenant's

        `report_builder_enabled` flag is off.


        The unit is the ATTEMPT, not the row: the original dial that the
        platform

        actually placed plus the corrections appended to it. `disposition` is
        that of

        the LAST settling correction (`in_flight` while there is none).
        `agent_id`

        follows assignment attribution (the original's agent, else the latest

        correction that names one). Each metric has ONE definition, shared with
        the

        fixed counters of `GET /v1/stats/summary`.


        `pii` is `null` on every published dimension: no dimension carries
        personal

        data (the phone number and the free-text note are not dimensions).


        `detail_columns` (core#1987) is the vocabulary of `mode: detail`: one
        row per

        attempt. A column whose `pii` is `phone` or `identifier` is MASKED by
        default

        (`***` + the last four characters, read from the stored mask without
        decrypting

        anything); `unmask: true` on `POST /v1/reports/run` returns it whole,
        for a `dd_`

        key or a login with the `account_admin` role only, and leaves an
        evidence row.

        The free-text `note` and `compliance_snapshot` are not columns.
      responses:
        "200":
          description: The catalog.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ReportCatalog"
              example:
                fact: call_attempts
                version: 1
                dimensions:
                  - key: campaign_id
                    type: uuid
                    pii: null
                    description: Campaign the attempt belongs to.
                  - key: disposition
                    type: string
                    pii: null
                    description: Disposition of the last settling correction; in_flight while it has
                      none.
                  - key: day
                    type: date
                    pii: null
                    description: Local calendar day of started_at in the request zone.
                metrics:
                  - key: attempts
                    unit: count
                    description: Attempts the platform actually placed.
                  - key: contact_rate_bp
                    unit: basis_points
                    description: Contacts over attempts in basis points; null when there are no
                      attempts.
                detail_columns:
                  - key: attempt_id
                    type: uuid
                    pii: null
                    description: The attempt.
                  - key: to_number
                    type: string
                    pii: phone
                    description: Dialed number. Masked (***0184) unless an authorized request sets
                      unmask.
                limits:
                  max_window_days: 31
                  max_groups: 1000
                  max_dimensions: 4
                  max_filter_values: 100
                  max_detail_rows: 1000
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/reports/run:
    post:
      operationId: runReport
      tags:
        - reports
      summary: Run one aggregate, or one page of attempts, of the report builder
      description: >
        Runs ONE aggregate over the closed catalog (`GET /v1/report-catalog`)
        for the

        caller's tenant (tenant scope: a `dd_` key or a dashboard login). The
        tenant

        is the principal's: a `tenant_id` in the body is `422 unknown_field`,
        like

        every other field outside `from`, `to`, `dimensions`, `metrics`,
        `filters`

        and `zone`.


        `from` and `to` are mandatory RFC 3339 timestamps; the window is

        `[from, to)` on `started_at` and at most 31 days (`400
        window_too_large`;

        `400` too when `to` is not after `from`). Up to 4 `dimensions` (none
        gives a

        single totals row) and at least one `metric`. `filters` narrows by the

        dimensions that are filterable, each with a non-empty list of at most
        100

        values; `agent_id` matches an attempt in which ANY of its rows names the

        agent. `zone` (default `UTC`) is an IANA `Area/Location` name and
        decides the

        calendar of the `day`, `week` and `month` dimensions.


        A request that would answer more than 1000 groups is refused whole

        (`422 too_many_groups`), never cut silently. The read runs under a

        statement timeout (`503 report_timeout`). Over the per-principal brake
        the

        answer is `429 rate_limited` with `Retry-After`. With the tenant's

        `report_builder_enabled` flag off the answer is `503
        report_builder_disabled`

        and nothing is read.


        `contact_rate_bp` is contacts over attempts in basis points, rounded
        half up,

        and `null` (never 0) when there are no attempts. The answer is JSON
        unless

        the query asks `?format=csv` (an `Accept` header alone is not enough):
        the

        CSV has one header row of the column keys, carries `X-Artifact-SHA256`
        and

        neutralises spreadsheet formulas.


        **Detail mode (core#1987).** `mode: detail` answers one row per placed
        attempt

        instead of a group, taking `columns` (from `detail_columns` of the
        catalog),

        `filters`, `unmask`, `limit` (default and maximum 1000) and `after` (the

        `next_cursor` of the previous page, a keyset over `(started_at, id)`,
        oldest

        first; a malformed one is `400 invalid_cursor`). `dimensions`, `metrics`
        and

        `zone` are aggregate-only and `columns`, `unmask`, `limit` and `after`
        are

        detail-only: the wrong mode's key is `422 field_not_for_mode`. The
        window and

        the brake are the aggregate's.


        PII columns (`pii: phone` or `identifier`) are masked by default from
        the

        stored `*_last4` columns, WITHOUT decrypting. `unmask: true` returns
        them whole

        and is allowed for a `dd_` key or a `ddu_` login with the
        `account_admin` role;

        a `member` gets `403 role_forbidden` (never a silently masked answer)
        and a

        `dds_` support session `403 forbidden` (its allowlist is GET-only).
        `unmask`

        without a PII column in `columns` is `422 unmask_without_pii_column`.
        Every

        unmasked page writes ONE row to the append-only evidence table in the
        SAME

        transaction as the read (actor, the definition, the columns, the
        unmasked

        columns, the row count; never the values); if that row cannot be
        written,

        nothing is returned. With `?format=csv` the page carries an
        `export_cursor`

        column on every row (the last one is the `after` of the next page) and

        `X-Artifact-SHA256`; an unmasked number comes out as `'+1312...` behind
        the

        formula guard.


        Instead of `dimensions`, `metrics` and `filters` the body may carry

        `definition_id`: a saved definition of the tenant
        (`/v1/report-definitions`).

        Both at once is `422 definition_conflict`; a malformed id is

        `422 invalid_definition_id`; an unknown id or another tenant's is `404`.
        The

        saved keys are checked against the CURRENT catalog on every run: one the

        catalog no longer publishes is `422 catalog_changed` naming it, and
        nothing

        is answered, not even a partial result. `from`, `to` and `zone` are
        always

        the run's own.

        A run by `definition_id` is always an aggregate: `mode: detail` with it
        is

        `422 field_not_for_mode`.
      parameters:
        - name: format
          in: query
          required: false
          description: "`csv` answers a CSV artifact; anything else answers JSON."
          schema:
            type: string
            enum:
              - csv
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ReportRunRequest"
            example:
              from: 2026-10-01T00:00:00Z
              to: 2026-10-08T00:00:00Z
              dimensions:
                - campaign_id
                - disposition
              metrics:
                - attempts
                - contacts
                - contact_rate_bp
              filters:
                source:
                  - platform
              zone: UTC
      responses:
        "200":
          description: The aggregate, or one page of the detail.
          headers:
            X-Artifact-SHA256:
              description: SHA-256 of the CSV body (only with `?format=csv`).
              schema:
                type: string
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ReportRunResult"
              example:
                fact: call_attempts
                catalog_version: 1
                from: 2026-10-01T00:00:00Z
                to: 2026-10-08T00:00:00Z
                zone: UTC
                columns:
                  - key: disposition
                    kind: dimension
                    type: string
                  - key: attempts
                    kind: metric
                    type: count
                  - key: contact_rate_bp
                    kind: metric
                    type: basis_points
                row_count: 2
                rows:
                  - disposition: answered_human
                    attempts: 40
                    contact_rate_bp: 10000
                  - disposition: no_answer
                    attempts: 60
                    contact_rate_bp: 0
            text/csv:
              schema:
                type: string
              example: "disposition,attempts,contact_rate_bp\r

                answered_human,40,10000\r\n"
        "400":
          description: The body is not a JSON object, `from`/`to` are missing or not RFC
            3339, `to` is not after `from`, the window is wider than 31 days
            (`window_too_large`), or `after` is not a valid cursor
            (`invalid_cursor`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: window_too_large
                  message: the window may not exceed 31 days
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >
            `role_forbidden`: `unmask: true` from a `member`. A `dds_` support
            session is refused

            (`forbidden`) by its GET-only allowlist. Nothing was read and no
            evidence row exists.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: role_forbidden
                  message: unmask takes a dd_ key or a dashboard login with the account_admin role
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          description: >
            Something outside the closed vocabulary or a list bound:
            `unknown_field`, `unknown_dimension`,

            `unknown_metric`, `unknown_column`, `unknown_filter`,
            `invalid_dimensions`, `invalid_metrics`,

            `invalid_columns`, `invalid_filters`, `invalid_zone`,
            `invalid_mode`, `invalid_limit`, `invalid_unmask`,

            `unmask_without_pii_column`, `field_not_for_mode`, or
            `too_many_groups` when the answer would

            exceed 1000 groups. With `definition_id`: `definition_conflict`,
            `invalid_definition_id`, or

            `catalog_changed` (the saved definition names a key the catalog no
            longer publishes; the

            message names it).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: unknown_metric
                  message: "unknown metric(s): revenue; published: attempts, contacts"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
        "503":
          description: The builder is off for this tenant (`report_builder_disabled`), or
            the read exceeded its statement timeout (`report_timeout`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: report_builder_disabled
                  message: the report builder is not enabled for this tenant
  /v1/report-definitions:
    get:
      operationId: listReportDefinitions
      tags:
        - reports
      summary: The tenant's saved report definitions
      description: >
        Every saved definition of the caller's tenant (tenant scope: a `dd_` key
        or a

        dashboard login), by `name`. A definition is a QUERY over the closed
        catalog

        (`dimensions`, `metrics`, `filters`), never its result: the window and
        the zone

        are parameters of each `POST /v1/reports/run`. Not gated by the tenant's

        `report_builder_enabled` flag: only running is.
      responses:
        "200":
          description: The definitions.
          content:
            application/json:
              schema:
                type: object
                required:
                  - report_definitions
                properties:
                  report_definitions:
                    type: array
                    items:
                      $ref: "#/components/schemas/ReportDefinition"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
    post:
      operationId: createReportDefinition
      tags:
        - reports
      summary: Save a report definition
      description: >
        Validates the body against the closed catalog exactly like an inline

        `POST /v1/reports/run`: a key the catalog does not publish is `422`

        (`unknown_dimension`, `unknown_metric`, `unknown_filter`, ...) and the
        message

        names it. The tenant and the author are the principal's: a `tenant_id`
        or

        `created_by` in the body is `422 unknown_field`. `created_by` is the
        user of a

        dashboard login and `null` for a `dd_` key. `name` is unique per tenant

        (`409 name_taken`). `fact` and `catalog_version` are stamped by the
        server.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ReportDefinitionInput"
            example:
              name: Contactabilidad por campaña
              dimensions:
                - campaign_id
              metrics:
                - attempts
                - contacts
                - contact_rate_bp
              filters:
                source:
                  - platform
      responses:
        "201":
          description: The saved definition.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ReportDefinitionResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "409":
          description: Another definition of this tenant already has the name
            (`name_taken`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: name_taken
                  message: a report definition with this name already exists for this tenant
        "422":
          $ref: "#/components/responses/Unprocessable"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/report-definitions/{id}:
    get:
      operationId: getReportDefinition
      tags:
        - reports
      summary: One saved report definition
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: The definition.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ReportDefinitionResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
    put:
      operationId: replaceReportDefinition
      tags:
        - reports
      summary: Replace a report definition
      description: >
        Full replacement of `name`, `dimensions`, `metrics` and `filters`,
        validated like

        the creation; `catalog_version` is refreshed to the current catalog.
        `id`,

        `created_by` and `created_at` never change.
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ReportDefinitionInput"
      responses:
        "200":
          description: The replaced definition.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ReportDefinitionResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: Another definition of this tenant already has the name
            (`name_taken`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: name_taken
                  message: a report definition with this name already exists for this tenant
        "422":
          $ref: "#/components/responses/Unprocessable"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
    delete:
      operationId: deleteReportDefinition
      tags:
        - reports
      summary: Delete a report definition
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "204":
          description: Deleted.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /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,

        disclosure_status, disclosure_script_match`. `disclosure_status` is what
        the agent

        DECLARED about the collection notice (`attested | declared_not_delivered
        |

        unattested | not_applicable | undecidable`, see
        `CallAttemptDisclosure`), derived by

        join like the rest of the row — a declaration, never "verified".

        `disclosure_script_match` (core#1691, ADR #219) is `matched |
        other_version |

        unscripted | not_applicable`, as `CallAttemptDisclosure.script_match`.


        `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.


        `campaign_id` (uuid) and `disposition` narrow the export like they
        narrow

        `GET /v1/call_attempts`; a malformed `campaign_id` is `400`, never
        ignored.

        The columns do not change. An `export_cursor` is only valid with the
        same

        filters it was produced under: resume a filtered export with the same

        `campaign_id`/`disposition`, and never carry its cursor into an
        unfiltered one.


        **PII (core#2018, ADR #240).** The export is redacted by default: a

        `debt_key` that carries a payment card number, an SSN or a bank account

        number comes out masked (`****-****-****-1234`, `***-**-1234`,
        `****1234`),

        keeping at most the last four digits. A CMS that reconciles by the

        original key asks for `include_pii=true`, which takes a `dd_` key or a

        dashboard login with the `account_admin` role (`403 role_forbidden`

        otherwise, never a silently redacted file) and is audited.
      parameters:
        - name: include_pii
          in: query
          description: >
            `true` returns the file WITHOUT PII redaction. Requires a `dd_` key
            or

            an `account_admin` login: `403 role_forbidden` for anyone else.
            Absent

            or `false` ⇒ redacted. Anything that is not a boolean ⇒ `400`.
          schema:
            type: boolean
            default: false
        - 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
        - $ref: "#/components/parameters/CampaignIdFilter"
        - name: disposition
          in: query
          description: >
            Exact match against the attempt's FINAL disposition, the value the
            CSV

            prints in `outcome` (a superseded earlier disposition does not
            match).

            Same vocabulary and the same `400`s as `DispositionFilter` on

            `GET /v1/call_attempts`: empty, repeated or array form ⇒ `400`; an
            unknown

            code ⇒ `200` with the header row only.
          schema:
            type: string
        - 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"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /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.


        **Unit of every count in this report** — `totals.unit`, plus the `unit`
        /

        `unit_description` rows of the CSV preamble — is

        `deduplicated_gate_block_days`: one deduplicated evidence row per unit,

        NOT one blocked call. The report counts TWO classes of row and they
        dedup

        on DIFFERENT keys: a campaign refusal counts once per

        `(tenant, debt, gate, campaign, UTC day)`; an ad-hoc refusal

        (`POST /v1/sms`, no debt and no campaign) counts once per

        `(tenant, gate, masked last-4, UTC day)`, which additionally collapses

        two different numbers sharing their last four digits (ADR #8; migration

        `20260728000002`). The headline number therefore UNDER-counts blocked

        attempts.


        **Audit filters** (`campaign_id`, `debt_id`, `phone`) narrow the WHOLE

        report — totals, `by_campaign`, `policy_versions` and `samples` — and
        are

        echoed in `filters` (and as `filter_*` rows of the CSV preamble) so a

        filtered artifact cannot pass for the tenant-wide one. With `debt_id` or

        `phone` set, `samples` holds up to 25 rows per gate instead of 3. There
        is

        no agent filter: a gate refuses a dial BEFORE any agent exists, and

        `gate_blocks` has no agent column.
      parameters:
        - $ref: "#/components/parameters/FromFilter"
        - $ref: "#/components/parameters/ToFilter"
        - $ref: "#/components/parameters/CampaignIdFilter"
        - $ref: "#/components/parameters/DebtIdFilter"
        - name: phone
          in: query
          description: >
            E.164 number (`+` percent-encoded as `%2B`); anything else is a 400.

            The table only keeps the masked form, so the filter matches on the

            LAST FOUR digits: two numbers sharing them are indistinguishable.
            The

            number itself is never echoed back (`filters.phone_last4` is).
          schema:
            type: string
            pattern: ^\+[1-9][0-9]{7,14}$
        - $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
                  unit: deduplicated_gate_block_days
                  unit_description: "one deduplicated evidence row per unit, NOT one blocked call:
                    a campaign refusal counts once per (tenant, debt, gate,
                    campaign, UTC day); an ad-hoc refusal (POST /v1/sms, no debt
                    and no campaign) counts once per (tenant, gate, masked
                    last-4, UTC day), which additionally collapses two different
                    numbers sharing their last four digits (ADR #8; migration
                    20260728000002)"
                  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"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/compliance/decisions/{decision_id}:
    get:
      operationId: getComplianceDecision
      tags:
        - compliance
      summary: Resolve and verify one decision_id in the audit chain
      description: >
        A refused operation hands you a `decision_id` (422
        `compliance_blocked`).

        This resolves that citation into its sealed link of the append-only

        compliance audit chain, together with the **integrity verdict** over it

        — so the claim is one you can check rather than one you have to accept.


        `decision.payload` is the canonical JSON that was hashed, shipped

        verbatim: re-run `sha256(prev_hash || canonical_json(payload))` yourself

        and compare against `verification.hash`. `canonical_json` sorts object

        keys lexicographically at every depth. All three hashes are lowercase

        hex sha256; `genesis_hash` is the fixed root of every tenant chain.


        **Three booleans, not one**, because three different things can be
        wrong:


        - `payload_hash_matches` — the payload is consistent with the hash
        stored
          BESIDE IT, in the same row. Read that literally: it proves internal
          consistency, NOT authenticity. An adversary with database access can
          rewrite `payload` and `hash` in one statement and turn this green.
        - `chains_to_previous` — it still hangs off the link before it (or off
          the genesis, when `seq` is 1). Deleting the PREVIOUS link leaves this
          one internally intact while the chain is broken.
        - `successor_chains_to_this` — the NEXT link still carries this link's
          hash. A coherent rewrite cannot fake this without re-chaining
          everything that came after, so it is what turns the first check from
          "consistent" into "unaltered".

        `status` is `intact` only when all three hold.


        **At the tip of the chain there is no successor**, and there a coherent

        rewrite is undetectable from inside the chain. That case is not dressed

        up as `intact`: `successor_chains_to_this` is `null`, `is_chain_tip` is

        `true` and `status` is `intact_unwitnessed`. It becomes `intact` on its

        own as soon as the next decision is sealed.


        Tamper detection is a `200` with `status: "tampered"`, not an error: the

        endpoint answering truthfully about a damaged chain IS the feature.


        **Not every `decision_id` we hand out has a link, and that is the one

        sharp edge of this surface.** The evidence table deduplicates refusals

        per day: a second refusal of the same debt (or, for ad-hoc sends, the

        same masked last-4) through the same gate on the same UTC day writes no

        new evidence row, and so seals no new link — while the 422 still returns

        a fresh `decision_id`. That citation resolves `404` here, and the `404`

        is deliberately byte-identical to the one for an id that never existed,

        so this route cannot be used to probe which ids are real. Concretely: a

        citation that does not resolve is NOT a denial that the refusal

        happened — that day's refusal is evidenced by the FIRST link for that

        debt (or masked number), gate and day.


        Scope: this resolves only the calling tenant's own decisions, and it

        never carries the E.164 — the sealed payload holds the masked last-4 by

        construction. Credential-less third-party verification is not part of

        this surface.
      parameters:
        - name: decision_id
          in: path
          required: true
          description: The `decision_id` handed back by the refusal.
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: The link and its integrity verdict.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AuditChainCitation"
              example:
                decision:
                  decision_id: 8c5a1f3e-2b7d-4e91-a0c6-3f5b9d2e7a41
                  tenant_id: 0d4f4f9e-1f2a-4b53-9d3c-8a5e2f7b1c10
                  seq: 42
                  channel: voice
                  allowed: false
                  reason_code: quiet_hours
                  rule_id: usa-cell.quiet-hours
                  policy_version: usa-cell.v1
                  subject_table: gate_blocks
                  subject_id: 5a4b3c2d-1e0f-4a9b-8c7d-6e5f4a3b2c1d
                  debt_ref: ACCT-10293
                  campaign_id: 3f2c1b0a-9d8e-4c7b-a6f5-4e3d2c1b0a99
                  to_number_masked: "***1001"
                  decided_at: 2026-07-01T02:10:00.123456Z
                  sealed_at: 2026-07-01T02:10:00.128901Z
                  payload:
                    decision_id: 8c5a1f3e-2b7d-4e91-a0c6-3f5b9d2e7a41
                    gate: pre_dial
                    reason_code: quiet_hours
                verification:
                  status: intact
                  payload_hash_matches: true
                  chains_to_previous: true
                  successor_chains_to_this: true
                  is_chain_tip: false
                  seq: 42
                  hash: 9f2c4d7a1b8e35c06a4f9d2e7b1c3a58d6f0e4b29c7a13d5e8f06b2c4a9d7e13
                  prev_hash: 3a7e1c9d5b2f84061e7d3c5a9b0f2e4d8c6a1b3f5d7e9c0a2b4d6f8e1a3c5b70
                  genesis_hash: cc0941d5e3377c3422517794a524f59be703c6a7e1a2bae2d596c34d784dddd7
                  algorithm: sha256
                  recipe: sha256(prev_hash || canonical_json(payload))
                  canonical_json: JSON with object keys sorted lexicographically at every depth
                  verified_at: 2026-07-05T10:00:00.000000Z
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: |
            No link resolves this id for the calling tenant. Byte-identical for
            every cause, by design (no existence oracle): the id never existed,
            it is malformed, it belongs to another tenant, or — the cause
            specific to this route — it was handed out by a refusal that
            deduplicated against an existing evidence row for that day and
            therefore sealed no link of its own. A `404` here is NOT a denial
            that the refusal happened.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: not_found
                  message: resource not found for this tenant
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/compliance/sources:
    get:
      operationId: getComplianceSources
      tags:
        - compliance
      summary: State of the compliance sources (DNC lists, state registries, RND)
      description: >
        What this tenant's dials are scrubbed against, and how fresh it is, as
        one cheap read for a

        settings panel. It reads the premises only: no campaign, no lead and no
        gate is evaluated

        (`GET /v1/campaigns/{id}/compliance-preflight` carries the same premises
        inside a per-campaign

        dry-run over up to 10000 leads, and reports age in days only).
        Read-only.


        **DNC** (`dnc`). The three global lists (`federal`, `state`,
        `internal_global`) and, under

        `state_registries`, every state registry the platform declares it
        scrubs, keyed by state code.

        The lists are global regulatory data, identical for every tenant of the
        cell. Each source

        carries the posture the gate acts on (`status`, `age_days`,
        `max_age_days` are the gate's own

        numbers, not a copy) plus the dates behind it: `updated_at` is when the
        SOURCE data was cut

        (not when the import ran), and `expires_at` is the first instant at
        which the gate treats the

        snapshot as `stale` and refuses to claim a scrub (`updated_at +
        (max_age_days + 1)` days: the

        gate compares whole days). `source` is the provenance string of the load
        (for example

        `ftc-san:2026-07-28`) and `record_count` the rows loaded. A list nobody
        imported is `absent`,

        with `updated_at`, `expires_at`, `age_days` and `record_count` null: the
        gate refuses over it.

        `max_age_days` is 31 for every list today (the FTC safe-harbor ceiling);
        a state registry

        reports its own.


        **Litigator** (`litigator`). Core has no litigator list of its own: the
        platform's litigators

        are rows of the `internal_global` DNC list, mixed with honeypots, and
        nothing distinguishes

        them. `available` is therefore always `false` and `covered_by` names the
        list that holds them,

        so a client can omit the entry instead of showing a freshness that does
        not exist.


        **RND** (`rnd`). The Reassigned Numbers Database. `provider` is what the
        cell declares

        (`declared`; `stub`, the development provider that answers clean without
        asking a registry; or

        `no_verificable`, none declared, and the gate refuses every dial). The
        answers are the tenant's,

        so `last_collected_at` is the last time a results file was collected for
        the calling tenant

        (null if none is recorded). `valid_since` is the cycle floor the gate
        applies (the last

        database update; answers collected at or before it no longer back the
        safe harbor) and

        `database_updated_at` the last update the platform observed, null while
        only the calendar rule

        applies. `status` compares the two: `current` (collected after
        `valid_since`), `stale`

        (collected before it) or `never`.
      responses:
        "200":
          description: State of every compliance source for the calling tenant.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ComplianceSources"
              example:
                as_of: 2026-10-15T09:30:00.000000Z
                dnc:
                  federal:
                    status: provisioned
                    source: ftc-san:2026-10-01
                    record_count: 240000000
                    updated_at: 2026-10-01T00:00:00.000000Z
                    expires_at: 2026-11-02T00:00:00.000000Z
                    age_days: 14
                    max_age_days: 31
                  state:
                    status: provisioned
                    source: states:2026-10-01
                    record_count: 1200000
                    updated_at: 2026-10-01T00:00:00.000000Z
                    expires_at: 2026-11-02T00:00:00.000000Z
                    age_days: 14
                    max_age_days: 31
                  state_registries:
                    FL:
                      status: provisioned
                      source: state-fl:2026-10-01
                      record_count: 700000
                      updated_at: 2026-10-01T00:00:00.000000Z
                      expires_at: 2026-11-02T00:00:00.000000Z
                      age_days: 14
                      max_age_days: 31
                    IN:
                      status: stale
                      source: state-in:2026-08-01
                      record_count: 90000
                      updated_at: 2026-08-01T00:00:00.000000Z
                      expires_at: 2026-09-02T00:00:00.000000Z
                      age_days: 75
                      max_age_days: 31
                    MO:
                      status: absent
                      source: none
                      record_count: null
                      updated_at: null
                      expires_at: null
                      age_days: null
                      max_age_days: 31
                  internal_global:
                    status: provisioned
                    source: platform
                    record_count: 1500
                    updated_at: 2026-10-15T00:00:00.000000Z
                    expires_at: 2026-11-16T00:00:00.000000Z
                    age_days: 0
                    max_age_days: 31
                litigator:
                  available: false
                  covered_by: internal_global
                rnd:
                  provider:
                    status: declared
                    provider: Dialer.Compliance.RND.Reader
                  status: current
                  last_collected_at: 2026-10-12T14:02:11.000000Z
                  valid_since: 2026-09-17T12:00:00.000000Z
                  database_updated_at: null
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/port-requests:
    get:
      operationId: listPortRequests
      tags:
        - dids
      summary: Port-in requests of the tenant
      description: Newest first, at most 50. The documents are never returned, only
        their hash and type.
      parameters:
        - name: status
          in: query
          schema:
            type: string
            enum:
              - pending
              - scheduled
              - rejected
              - completed
      responses:
        "200":
          description: The requests.
          content:
            application/json:
              schema:
                type: object
                required:
                  - port_requests
                properties:
                  port_requests:
                    type: array
                    items:
                      $ref: "#/components/schemas/PortRequest"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
    post:
      operationId: createPortRequest
      tags:
        - dids
      summary: Port numbers in to the platform (port-in)
      description: >
        Registers a port-in request and sends it to the house carrier. Documents
        travel as base64 in

        the JSON body (each at most 2 MiB; `application/pdf`, `image/png` or
        `image/jpeg`, checked

        against their magic bytes) and are stored ENCRYPTED, never returned.
        Fails closed before

        the carrier is called: inactive plan (`402 plan_inactive`), invalid
        input (`400`), a number

        already in the registry or in a live request (`409 number_in_use`). A
        `test` key is refused

        (`403 test_environment`). The carrier rejecting the submission is NOT an
        error: the request

        is stored with `status: rejected` and its `reject_reason`. `503
        carrier_unavailable`: the carrier

        did not answer; the request is kept as `rejected`
        (`carrier_unavailable_at_submission`) and its

        numbers are free to retry. `409 port_in_busy`: another request of the
        tenant is being registered

        (retry shortly; nothing stored or sent). `429 port_request_limit`: the
        tenant already has

        `port_request_max_live` (default 20) live requests; nothing stored or
        sent. `503 port_in_unavailable`: this deployment has no port-in carrier

        configured; nothing was stored or sent.


        Status follows the carrier (`pending` → `scheduled` with `foc_date` →
        `completed`, or

        `rejected` with `reject_reason`). On `completed` the numbers become
        `origin: house` DIDs of

        the tenant (they accrue the monthly rent) and the signed webhook
        `port_request.ported` is sent.


        Role rules: a `dd_` key or an `account_admin` login (`403
        role_forbidden` otherwise).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required:
                - numbers
                - account_holder
                - loa
                - invoice
              properties:
                numbers:
                  type: array
                  minItems: 1
                  maxItems: 100
                  items:
                    type: string
                    description: US/Canada E.164, e.g. +13125550142.
                account_holder:
                  type: string
                  maxLength: 200
                  description: Name on the account at the current carrier.
                loa:
                  $ref: "#/components/schemas/PortDocument"
                invoice:
                  $ref: "#/components/schemas/PortDocument"
      responses:
        "201":
          description: The stored request.
          content:
            application/json:
              schema:
                type: object
                required:
                  - port_request
                properties:
                  port_request:
                    $ref: "#/components/schemas/PortRequest"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PlanInactive"
        "403":
          $ref: "#/components/responses/Forbidden"
        "409":
          $ref: "#/components/responses/Conflict"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
        "503":
          description: The carrier did not answer (`carrier_unavailable`) or this
            deployment has no port-in carrier configured
            (`port_in_unavailable`); nothing was stored or sent.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
  /v1/port-requests/{id}:
    get:
      operationId: getPortRequest
      tags:
        - dids
      summary: Fetch one port-in request
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: The request with the carrier status.
          content:
            application/json:
              schema:
                type: object
                required:
                  - port_request
                properties:
                  port_request:
                    $ref: "#/components/schemas/PortRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /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
                  - truncated
                properties:
                  dids:
                    type: array
                    items:
                      $ref: "#/components/schemas/DID"
                  truncated:
                    type: boolean
                    description: >
                      `true` when there are more rows than the 2 000 numbers the
                      listing returns: the response is a

                      prefix in the documented order, not the whole set.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
    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`).


        Role rules (core#2439): a `dd_` key or an `account_admin` login; a
        `member` login is `403 role_forbidden` (per-tenant knob
        `telephony_config_admin_only`, default on). The `GET` of this resource
        is not affected.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              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
                  description: "Accepted and IGNORED (core#2415, F-03): the STIR/SHAKEN
                    attestation is a claim only the platform admin records
                    (`POST /v1/admin/tenants/{id}/dids/{did_id}/verify`); it
                    never changes through this route, which answers as if the
                    key were absent."
                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"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /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"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
    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.


        Role rules (core#2439): a `dd_` key or an `account_admin` login; a
        `member` login is `403 role_forbidden` (per-tenant knob
        `telephony_config_admin_only`, default on). The `GET` of this resource
        is not affected.
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              properties:
                npa:
                  type: string
                us_state:
                  type: string
                attestation:
                  type: string
                  enum:
                    - A
                    - B
                    - C
                    - unknown
                  description: "Accepted and IGNORED (core#2415, F-03): the STIR/SHAKEN
                    attestation is a claim only the platform admin records
                    (`POST /v1/admin/tenants/{id}/dids/{did_id}/verify`); it
                    never changes through this route, which answers as if the
                    key were absent."
                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"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
    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.


        Role rules (core#2439): a `dd_` key or an `account_admin` login; a
        `member` login is `403 role_forbidden` (per-tenant knob
        `telephony_config_admin_only`, default on). The `GET` of this resource
        is not affected.
      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"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /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"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/dids/{id}/launch-checklist:
    get:
      operationId: didLaunchChecklist
      tags:
        - dids
      summary: Launch checklist of a number
      description: >
        Four steps, each stating its `source`. `attestation` is `derived` from
        the

        registry (`A`/`B`/`C` = done; the value is what the tenant declared, not
        a

        carrier verdict). `warmup` is `derived` from the number's own CDR: done
        once

        it has dialled on `warmup_min_active_days` distinct UTC days of the
        trailing

        30. `registration` and `cnam` are `manual`: the platform has no data for

        them, so they are what the operator confirmed. No external reputation
        feed

        is consulted.
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: The checklist.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DidLaunchChecklist"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/dids/{id}/launch-checklist/{step}/confirmations:
    post:
      operationId: confirmDidLaunchStep
      tags:
        - dids
      summary: Confirm (or un-confirm) a manual launch step
      description: >
        Appends a confirmation for a `manual` step (`registration` or `cnam`);
        the

        latest one is the step's state and none is ever edited or deleted. A

        `derived` step (`attestation`, `warmup`) is `422`: the platform computes
        it.

        An unknown step is `404`. Returns the fresh checklist.
      parameters:
        - $ref: "#/components/parameters/PathId"
        - name: step
          in: path
          required: true
          schema:
            type: string
            enum:
              - registration
              - cnam
              - attestation
              - warmup
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - confirmed
              additionalProperties: false
              properties:
                confirmed:
                  type: boolean
                note:
                  type: string
                  maxLength: 1000
      responses:
        "201":
          description: The checklist after the confirmation.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DidLaunchChecklist"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/Unprocessable"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/dids/{id}/carrier-tickets:
    get:
      operationId: listDidCarrierTickets
      tags:
        - dids
      summary: Carrier tickets of a number
      description: >
        Tickets the tenant keeps about this number with its carrier, most
        recently

        active first, each with its full append-only thread. A retired number
        still

        serves its tickets.
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: The tickets.
          content:
            application/json:
              schema:
                type: object
                required:
                  - tickets
                properties:
                  tickets:
                    type: array
                    items:
                      $ref: "#/components/schemas/DidCarrierTicket"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
    post:
      operationId: openDidCarrierTicket
      tags:
        - dids
      summary: Open a carrier ticket for a number
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - carrier
                - subject
              additionalProperties: false
              properties:
                carrier:
                  type: string
                  minLength: 1
                  maxLength: 100
                subject:
                  type: string
                  minLength: 1
                  maxLength: 200
                reference:
                  type: string
                  maxLength: 200
                  description: The carrier's own ticket reference, if it gave one.
                note:
                  type: string
                  maxLength: 4000
      responses:
        "201":
          description: The opened ticket (status `open`).
          content:
            application/json:
              schema:
                type: object
                required:
                  - ticket
                properties:
                  ticket:
                    $ref: "#/components/schemas/DidCarrierTicket"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/dids/{id}/carrier-tickets/{ticket_id}/events:
    post:
      operationId: addDidCarrierTicketEvent
      tags:
        - dids
      summary: Add a note and/or move a carrier ticket's status
      description: |
        Appends to the ticket's thread; nothing is edited or deleted. `status`
        defaults to the current one. An event with neither a note nor a status
        change is `400`.
      parameters:
        - $ref: "#/components/parameters/PathId"
        - name: ticket_id
          in: path
          required: true
          description: Ticket UUID. It must belong to the number in the path, else `404`.
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              properties:
                status:
                  type: string
                  enum:
                    - open
                    - in_progress
                    - resolved
                    - closed
                note:
                  type: string
                  maxLength: 4000
      responses:
        "201":
          description: The ticket with the new event appended.
          content:
            application/json:
              schema:
                type: object
                required:
                  - ticket
                properties:
                  ticket:
                    $ref: "#/components/schemas/DidCarrierTicket"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/support/tickets:
    get:
      operationId: listSupportTickets
      tags:
        - support
      summary: The tenant's support tickets
      description: Newest first, capped at 200. Filter with `?status=`.
      parameters:
        - name: status
          in: query
          required: false
          schema:
            type: string
            enum:
              - open
              - answered
              - resolved
              - closed
      responses:
        "200":
          description: The tickets (no thread; fetch one for its messages).
          content:
            application/json:
              schema:
                type: object
                required:
                  - tickets
                properties:
                  tickets:
                    type: array
                    items:
                      $ref: "#/components/schemas/SupportTicket"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
    post:
      operationId: openSupportTicket
      tags:
        - support
      summary: Open a support ticket
      description: >
        Returns the ticket `number` (reference `TKT-<number>`) and starts the

        first-response clock. The target depends on the plan level — `standard`

        (Starter): 1 business day; `priority` (Growth, Scale, Enterprise): 4

        business hours — counted Monday to Friday UTC. `first_response_due_at`

        is that instant. The operator's answer is e-mailed to `contact_email`

        (default: the e-mail of the dashboard user who opens it; a `dd_` key
        with none

        sends no e-mail). `contact_email` must be the address of an ACTIVE user
        of the

        tenant, else `422 invalid_contact_email` (core#2927). A tenant holds at
        most

        `support_tickets_max_open` unfinished tickets (`open` + `answered`;
        default 30):

        over it `409 ticket_limit`, an answer or a close frees a slot. Opening
        is also

        rate-limited per tenant (`429` with `Retry-After`).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - subject
                - body
              additionalProperties: false
              properties:
                subject:
                  type: string
                  minLength: 1
                  maxLength: 200
                body:
                  type: string
                  minLength: 1
                  maxLength: 10000
                priority:
                  type: string
                  enum:
                    - low
                    - normal
                    - high
                    - urgent
                  default: normal
                request_id:
                  type: string
                  maxLength: 200
                  description: The `x-request-id` of the API call the ticket is about, if any.
                contact_email:
                  type: string
                  maxLength: 320
      responses:
        "201":
          description: The opened ticket, with its first message.
          content:
            application/json:
              schema:
                type: object
                required:
                  - ticket
                properties:
                  ticket:
                    $ref: "#/components/schemas/SupportTicketDetail"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "409":
          $ref: "#/components/responses/Conflict"
        "422":
          $ref: "#/components/responses/Unprocessable"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/support/tickets/{id}:
    get:
      operationId: getSupportTicket
      tags:
        - support
      summary: A support ticket with its thread
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: The ticket.
          content:
            application/json:
              schema:
                type: object
                required:
                  - ticket
                properties:
                  ticket:
                    $ref: "#/components/schemas/SupportTicketDetail"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/support/tickets/{id}/messages:
    post:
      operationId: addSupportTicketMessage
      tags:
        - support
      summary: Add a message to a ticket
      description: Reopens an answered or resolved ticket. `409` when it is closed.
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - body
              additionalProperties: false
              properties:
                body:
                  type: string
                  minLength: 1
                  maxLength: 10000
      responses:
        "201":
          description: The ticket with the new message.
          content:
            application/json:
              schema:
                type: object
                required:
                  - ticket
                properties:
                  ticket:
                    $ref: "#/components/schemas/SupportTicketDetail"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/support/tickets/{id}/close:
    post:
      operationId: closeSupportTicket
      tags:
        - support
      summary: Close a ticket
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: The closed ticket.
          content:
            application/json:
              schema:
                type: object
                required:
                  - ticket
                properties:
                  ticket:
                    $ref: "#/components/schemas/SupportTicketDetail"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /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"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
    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.


        `source_ips` is accepted with both scopes. With `scope: "empresa"` the

        trunk carries INBOUND too (ADR #203): the IPs identify the account at

        the SIP edge (`byoctenant[<ip>] = acct:<account>`), and the destination

        is decided by the dialed number — a DID of any sede of the account —

        not by the IP. An address belongs to ONE trunk platform-wide: one

        already assigned to another trunk (of any sede or empresa) is a **400**

        (`already assigned to another trunk`). An empty list is accepted: the

        empresa-wide trunk then carries OUTBOUND only.


        `allowed_destinations` (core#1281, ADR #172) is the destination

        allowlist of a BYOC trunk: exact E.164 numbers, at most 100, no

        duplicates. While any active BYOC trunk a sede egresses through declares

        one, the platform places no call of that sede — campaign, manual dial,

        callback or external transfer leg — towards a number outside every

        declared list; each refusal is a `tenant_policy` row in the blocked

        stats with rule `egress.destination_not_allowed.v1`. A list that cannot

        be read refuses too (`egress.destination_allowlist_unavailable.v1`).

        Absent or `[]` = no restriction. On a `house` trunk a non-empty list is

        a **400**.


        `max_concurrent_calls`, `max_daily_attempts` and `allowed_dial_modes`

        (core#2176) are the contract limits of a BYOC trunk — the terms the

        client has with the carrier. All three are optional and `null`/absent is

        no limit, which is how a trunk behaved before they existed. The platform

        enforces them on every dial that leaves through the trunk, fail-closed:

        a dial whose mode is outside `allowed_dial_modes`, or that finds the

        trunk at `max_concurrent_calls` live calls or at `max_daily_attempts`

        attempts of the UTC day, is not placed and is filed as a `tenant_policy`

        row in the blocked stats with rule `egress.dial_mode_not_allowed.v1`,

        `egress.carrier_channels_full.v1` or
        `egress.daily_attempts_exhausted.v1`.

        A limit that cannot be read or counted refuses too

        (`egress.trunk_limits_unavailable.v1`). Both counts are derived from the

        call records, and an empresa-wide trunk counts the calls of every sede

        of the account. On a `house` trunk a limit is a **400**.


        The destination of a BYOC trunk (`sip_proxy`, core#2345) is the SIP

        edge's next hop as written, so it has to be a public `sip:`/`sips:`

        endpoint. It is a **422** when it is malformed or points to the

        platform's own network: a loopback, private (RFC 1918), link-local,

        CGNAT (100.64/10), multicast or unspecified address, a name internal to

        the cell (`localhost`, a single label, `.local`, `.internal`, `.svc`,

        ...) or a range the cell declares as its own. It is a **409** with code

        `trunk_endpoint_shared` when its host and port are already the endpoint

        of a platform carrier or of an active BYOC trunk of another account: two

        accounts cannot share one destination. Two sedes of the SAME account

        can. A `house` trunk and a BYOC trunk with no `sip_proxy` are not

        checked, and nothing is written when the answer is an error.


        Role rules (core#2439): a `dd_` key or an `account_admin` login; a
        `member` login is `403 role_forbidden` (per-tenant knob
        `telephony_config_admin_only`, default on). The `GET` of this resource
        is not affected.


        core#2424 (F-13), behind the flag `carrier_source_ip_class_check` (ON by

        default): from this surface only `kind: "byoc"` is accepted (a `house`

        trunk is the platform admin's, through `/v1/admin/carriers`), and a

        `source_ips` entry in a special-use range (loopback, private RFC 1918,

        CGNAT, link-local, documentation, multicast, reserved, and their IPv6

        equivalents) is refused. Both answer **422** naming the field (`kind` or

        `source_ips`). Rows already stored are not changed.


        A `test` API key (core#2965) is refused with `403 test_environment`: it
        does not create, change,

        retire or provision the real trunks, providers or routes of the tenant.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              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
                  description: "Bare IPv4/IPv6 addresses of the trunk's peers (inbound ACL),
                    stored canonicalized and unique across the platform, with
                    either scope. With `scope: \"empresa\"` they identify the
                    account, not a sede: the dialed number decides which sede
                    takes the call (ADR #203)."
                status:
                  type: string
                  enum:
                    - active
                    - disabled
                    - retired
                allowed_destinations:
                  $ref: "#/components/schemas/CarrierAllowedDestinations"
                max_concurrent_calls:
                  $ref: "#/components/schemas/CarrierMaxConcurrentCalls"
                max_daily_attempts:
                  $ref: "#/components/schemas/CarrierMaxDailyAttempts"
                allowed_dial_modes:
                  $ref: "#/components/schemas/CarrierAllowedDialModes"
                srtp:
                  $ref: "#/components/schemas/CarrierSrtp"
                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"
        "409":
          description: >
            `trunk_endpoint_shared`: the `sip_proxy` host and port are already
            the

            endpoint of a platform carrier or of an active BYOC trunk of another

            account. The answer does not say whose.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: trunk_endpoint_shared
                  message: sip_proxy is already the endpoint of another trunk and cannot be used
                    for this one
        "422":
          description: >
            The `sip_proxy` of a BYOC trunk is not a public SIP endpoint
            (core#2345):

            malformed, or a loopback, private, link-local, CGNAT, multicast or

            unspecified address, an internal name, or a range the cell declares
            as

            its own. The message names the closed reason. Also (core#2424): a

            `kind: "house"` body, or a `source_ips` entry in a special-use
            range,

            refused while the flag `carrier_source_ip_class_check` is on; the

            message names the field (`kind` or `source_ips`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: unprocessable
                  message: "sip_proxy is not an allowed destination for a BYOC trunk (private): it
                    must be a public sip:/sips: endpoint"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /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"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
    patch:
      operationId: updateCarrierAllowedDestinations
      tags:
        - carriers
      summary: Replace the destination allowlist and the contract limits of a BYOC trunk
      description: >
        core#1281 (ADR #172) and core#2176. What a trunk changes after its

        alta: the destination allowlist (`allowed_destinations`, the whole list

        is replaced; `[]` or `null` lifts the restriction) and its contract

        limits (`max_concurrent_calls`, `max_daily_attempts`,

        `allowed_dial_modes`; `null` lifts each — see `POST /v1/carriers`). At

        least one of the four keys is required; a key that is present replaces

        the stored value and one that is absent is left as it was. Any other

        key is a **400** naming it — `sip_proxy`, `source_ips` and the scope are

        provisioned into the SIP edge and are not edited in place.


        An empresa-wide trunk restricts every sede that inherits it, so

        changing its list takes an `account_admin` user (**403** otherwise),

        like creating it. Another empresa's trunk, or the platform house trunk,

        is a **404**; a retired trunk is a **409**. On a `house` trunk a

        non-empty list is a **400**.


        Role rules (core#2439): a `dd_` key or an `account_admin` login; a
        `member` login is `403 role_forbidden` (per-tenant knob
        `telephony_config_admin_only`, default on). The `GET` of this resource
        is not affected.


        A `test` API key (core#2965) is refused with `403 test_environment`: it
        does not create, change,

        retire or provision the real trunks, providers or routes of the tenant.
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              minProperties: 1
              properties:
                allowed_destinations:
                  oneOf:
                    - $ref: "#/components/schemas/CarrierAllowedDestinations"
                    - type: "null"
                max_concurrent_calls:
                  $ref: "#/components/schemas/CarrierMaxConcurrentCalls"
                max_daily_attempts:
                  $ref: "#/components/schemas/CarrierMaxDailyAttempts"
                allowed_dial_modes:
                  $ref: "#/components/schemas/CarrierAllowedDialModes"
            example:
              allowed_destinations:
                - "+13125550100"
                - "+13125550101"
              max_concurrent_calls: 1
              max_daily_attempts: 20
              allowed_dial_modes:
                - manual
                - preview
      responses:
        "200":
          description: The trunk with its new list and limits.
          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"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/carriers/{id}/health:
    get:
      operationId: getCarrierHealth
      tags:
        - carriers
      summary: Health of a BYOC trunk (up | down | unknown)
      description: >
        core#1522 (ADR #196). The state NOW of a BYOC trunk: the latest event of

        an append-only stream, fed by the SIP edge's OPTIONS probe
        (reachability,

        works with no traffic) and corroborated by the CDR (quality: an answer

        ratio over the last 10 minutes, ignored below 30 attempts).


        `unknown` is a first-class state, never a polite `up`: a trunk with no

        measurement yet, one the evaluator could not read for two ticks, and one

        whose last event is older than 15 minutes (`fresh: false`, `reason:

        stale` — nobody could write) all read `unknown`. A `disabled` or

        `retired` trunk reads `unknown` with `reason: carrier_inactive` and

        `signals: null`. The health is informational: routing is not rewritten

        by it.


        Another sede's or account's trunk, a made-up id, a malformed id and the

        platform house trunk are all a **404**. The response never carries the

        trunk's `sip_proxy`.
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: The trunk's health now.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TrunkHealth"
              example:
                carrier_id: 6f1c2d4e-8a3b-4c5d-9e7f-0a1b2c3d4e5f
                state: up
                reason: probe_active
                since: 2026-10-05T00:41:00Z
                observed_at: 2026-10-05T00:51:00Z
                fresh: true
                signals:
                  probe:
                    replicas:
                      up: 2
                      down: 0
                      fresh: 2
                    latency_ms: 38.5
                  cdr:
                    window_s: 600
                    attempts: 120
                    failures: 3
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/carriers/{id}/health/events:
    get:
      operationId: listCarrierHealthEvents
      tags:
        - carriers
      summary: History of a BYOC trunk's health transitions
      description: |
        core#1522 (ADR #196). The rows of the trunk's health stream, newest
        first, keyset-paginated like `/v1/call_attempts`. By default only the
        TRANSITIONS: the `confirm` rows (same state, one every 5 minutes, they
        only prove freshness) are left out unless `include_confirm=true`.
        Same 404 rule as `GET /v1/carriers/{id}/health`; a retired trunk keeps
        its history.
      parameters:
        - $ref: "#/components/parameters/PathId"
        - name: include_confirm
          in: query
          description: Also list the 5-minute `confirm` rows (default `false`).
          schema:
            type: boolean
            default: false
        - $ref: "#/components/parameters/LimitParam"
        - $ref: "#/components/parameters/CursorParam"
      responses:
        "200":
          description: One page of health events.
          content:
            application/json:
              schema:
                type: object
                required:
                  - events
                  - next_cursor
                properties:
                  events:
                    type: array
                    items:
                      $ref: "#/components/schemas/TrunkHealthEvent"
                  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"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/carriers/{id}/provision:
    post:
      operationId: provisionCarrier
      tags:
        - carriers
      summary: Push the trunk to the SIP edge (Kamailio)
      description: >
        A BYOC carrier pushes its routing to the edge. A per-sede trunk writes

        its outbound egress and its inbound ACL (`source_ips`). An empresa-wide

        trunk writes the outbound egress of every sede of the account that has

        no active BYOC trunk of its own — a sede's own trunk wins — and its

        inbound ACL (`source_ips`) ONCE for the whole account, not per sede.


        A HOUSE carrier is a no-op `200 {"status": "provisioned"}`: house calls

        egress over the platform's default routing, so there is nothing to push.


        A revoked BYOC carrier is `409`: revoking retires it for good, and only
        an

        active trunk can be the one a sede egresses through.


        The stored `sip_proxy` is checked again before anything is pushed

        (core#2345), with the same rule as the alta: a destination that is not a

        public SIP endpoint is `422`, and one that is already the endpoint of a

        platform carrier or of an older active BYOC trunk of another account is

        `409` with code `trunk_endpoint_shared`. Nothing is pushed in either
        case.


        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.


        Role rules (core#2439): a `dd_` key or an `account_admin` login; a
        `member` login is `403 role_forbidden` (per-tenant knob
        `telephony_config_admin_only`, default on). The `GET` of this resource
        is not affected.
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: Provisioned (house included, as a no-op), 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"
        "409":
          $ref: "#/components/responses/Conflict"
        "422":
          $ref: "#/components/responses/Unprocessable"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
        "502":
          $ref: "#/components/responses/SbcError"
  /v1/carriers/{id}/revoke:
    post:
      operationId: revokeCarrier
      tags:
        - carriers
      summary: Remove the trunk from the SIP edge
      description: >
        Only a BYOC carrier can be revoked: a HOUSE carrier is `422`. Revoking

        retires the carrier for good (it can no longer be provisioned) and frees

        its `source_ips` for another trunk. Idempotent: revoking it again only

        re-syncs the edge.


        Each affected sede then egresses through the trunk it is left with, the

        same one its calls are attributed to: its own most recent active trunk,

        else the account's most recent empresa-wide one, else the house route.

        Revoking a trunk a sede was not egressing through leaves that sede's

        egress unchanged. The affected sedes are the trunk's own sede, or, for
        an

        empresa-wide trunk, the same sedes provisioning writes (those without an

        active BYOC trunk of their own).


        Revoking an empresa-wide trunk changes the routing of every sibling
        sede,

        so it takes an `account_admin` (`403` otherwise), as creating one does.


        The inbound ACL of an empresa-wide trunk (`source_ips`, written as

        `acct:<account>` entries) is removed first, once for the whole account,

        and only then is the egress of the sedes re-synced.


        Role rules (core#2439): a `dd_` key or an `account_admin` login; a
        `member` login is `403 role_forbidden` (per-tenant knob
        `telephony_config_admin_only`, default on). The `GET` of this resource
        is not affected.


        A `test` API key (core#2965) is refused with `403 test_environment`: it
        does not create, change,

        retire or provision the real trunks, providers or routes of the tenant.
      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"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/Unprocessable"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
        "502":
          $ref: "#/components/responses/SbcError"
  /v1/sandbox/records:
    get:
      operationId: listSandboxRecords
      tags:
        - sms
      summary: The test CDR of the sandbox
      description: >
        The simulated calls and SMS of the tenant's `dd_test_` keys (core#2847),
        newest first,

        APART from the real CDR: `/v1/reports/cdr` and `/v1/sms/messages` never
        include them,

        and they are neither metered nor charged. With a `dd_test_` key, `POST
        /v1/dial/manual`

        (`number`) and `POST /v1/sms/messages` (`to`, `body`) answer a
        deterministic simulated

        result chosen by the destination, and reach no carrier or provider:


        | number | call `status` | SMS `status` |

        |---|---|---|

        | `+13125550100` | `completed` | `delivered` |

        | `+13125550101` | `busy` | `undelivered` |

        | `+13125550102` | `no_answer` | `sent` |

        | `+13125550103` | `failed` | `failed` |


        Any other destination is `422 unknown_test_number`. A simulated call
        also queues

        `call.answered` (answered only) and `call.ended` to the tenant's
        subscribed endpoints,

        signed with `X-DD-Signature` like a real one, with `livemode: false` and

        `environment: "test"` in the envelope. SMS has no webhook (no
        outbound-SMS event exists).
      parameters:
        - name: kind
          in: query
          required: false
          schema:
            type: string
            enum:
              - call
              - sms
        - name: limit
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 200
            default: 50
      responses:
        "200":
          description: The test records.
          content:
            application/json:
              schema:
                type: object
                required:
                  - environment
                  - records
                properties:
                  environment:
                    type: string
                    enum:
                      - test
                  records:
                    type: array
                    items:
                      type: object
                      required:
                        - id
                        - environment
                        - kind
                        - to_number
                        - outcome
                        - status
                        - started_at
                      properties:
                        id:
                          type: string
                          format: uuid
                        environment:
                          type: string
                          enum:
                            - test
                        kind:
                          type: string
                          enum:
                            - call
                            - sms
                        to_number:
                          type: string
                        outcome:
                          type: string
                          enum:
                            - answered
                            - busy
                            - no_answer
                            - failed
                        status:
                          type: string
                        client_ref:
                          type:
                            - string
                            - "null"
                        body_length:
                          type:
                            - integer
                            - "null"
                        started_at:
                          type: string
                          format: date-time
                        answered_at:
                          type:
                            - string
                            - "null"
                          format: date-time
                        ended_at:
                          type:
                            - string
                            - "null"
                          format: date-time
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /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 request with no `from` whose resolved

        provider has no `default_from_e164` →

        `422 from_required`: nothing is queued and the provider is not contacted

        (the conversation line cannot be decided, so the send fails closed). The
        sender

        (`from`, or the provider's `default_from_e164`) must be E.164 and an
        ACTIVE number

        of the tenant: otherwise `422 sms_from_invalid` / `422
        sms_from_not_owned` —

        nothing is queued and the provider is not contacted (per-tenant knob

        `sms_from_ownership_enforced`, default `true`; core#2329). 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. A prepaid tenant
        whose

        send has no sell rate (no SMS segment rate for the region) →

        `422 no_rate`: refused, but not a payment refusal — topping up changes

        nothing, and retrying unchanged will refuse again.


        Before any of that — before the plan gate, the brakes and the engine —
        the

        body itself is checked, with ZERO provider calls and ZERO ledger rows on
        a

        refusal. A body that names no honored opt-out keyword →

        `422 sms_optout_notice_missing`. A body that names a carrier-prohibited
        term

        (SHAFT: sex, hate, alcohol, firearm(s), tobacco; debt relief,
        consolidation,

        forgiveness, credit repair — whole words, case-insensitive) →

        `422 sms_content_prohibited` (ADR #181). Neither has a per-tenant or

        per-campaign switch; rewording the body is the only way through.


        A sender that is a US local (10DLC) number with no `approved` 10DLC
        campaign →

        `422 sms_10dlc_campaign_required`, with ZERO provider calls and ZERO
        ledger rows. There

        is no per-request switch: assign the number to an approved campaign

        (`POST /v1/messaging/campaigns/{id}/numbers`). Toll-free senders and
        regions other than

        `us` are not gated by 10DLC.
      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; no
                    carrier-prohibited term, see above).
                from:
                  type: string
                  description: "Sender E.164 / short code. Optional: when absent core uses the
                    `default_from_e164` of the resolved provider and sends
                    THROUGH that line, so the outbound and the consumer's reply
                    share one thread. Neither present → `422 from_required`.
                    Whichever applies must be E.164 and an ACTIVE number of the
                    tenant (`422 sms_from_invalid` / `422 sms_from_not_owned`).
                    With the tenant key `did_ownership_proof` in `enforce`
                    (core#2415), a `customer` number whose ownership was never
                    verified is `422 did_unverified`."
                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"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "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"
        - name: thread_id
          in: query
          description: Only the rows of one inbox thread (`thread_id` of
            `/v1/sms/threads`). A malformed id is `400`; an id of another tenant
            matches nothing.
          schema:
            type: string
            format: uuid
        - $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"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /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"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/sms/threads:
    get:
      operationId: listSmsThreads
      tags:
        - sms
      summary: SMS inbox - one row per consumer/line thread
      description: >
        The inbox read-model (core#1775, ADR 228): one row per thread - the

        ordered pair (consumer number, our line) - with activity in the window,

        newest first by the thread's last original message. DERIVED on read from

        the append-only ledger: no table, counter or materialisation. A delivery

        transition never moves a thread; only an original message does.


        `from`/`to` default to the trailing 31 days; a window wider than 31
        days,

        or with `from` after `to`, is `400`. The last message shown is the last

        one INSIDE the window.


        `unread=true` keeps only threads with an inbound message in the window

        that is newer than the thread's last `read` event

        (`POST /v1/sms/threads/{id}/read`, core#1776); with no read event every

        inbound counts. `assigned_to` is the user the thread was last assigned

        to (`POST /v1/sms/threads/{id}/assign`), or null.


        Messages written before threads existed carry no thread and never appear

        here. A response never carries the thread's identity digest: `thread_id`

        is the only handle. The list shows a 40-char preview of the last body.
      parameters:
        - $ref: "#/components/parameters/FromFilter"
        - $ref: "#/components/parameters/ToFilter"
        - name: unread
          in: query
          description: "`true` keeps only threads with an inbound message in the window."
          schema:
            type: boolean
        - 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 threads.
          content:
            application/json:
              schema:
                type: object
                required:
                  - sms_threads
                  - next_cursor
                properties:
                  sms_threads:
                    type: array
                    items:
                      $ref: "#/components/schemas/SmsThread"
                  next_cursor:
                    oneOf:
                      - type: string
                      - type: "null"
              example:
                sms_threads:
                  - thread_id: 5b1c0d2e-3f4a-4b5c-8d6e-7f8091a2b3c4
                    tenant_id: 9c8d7e6f-5a4b-4c3d-2e1f-0a9b8c7d6e5f
                    consumer_number: "+19995550123"
                    line_number: "+13125550199"
                    last_message_id: 0e9f8a7b-6c5d-4e3f-2a1b-0c9d8e7f6a5b
                    last_direction: inbound
                    last_body_preview: Gracias, pago el viernes. Me pueden llamar d…
                    last_message_at: 2026-10-07T10:00:00Z
                    unread: true
                    assigned_to: null
                next_cursor: null
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/sms/threads/{id}:
    get:
      operationId: getSmsThread
      tags:
        - sms
      summary: One SMS inbox thread with the debts of its consumer number
      description: >
        The list row of `/v1/sms/threads` for one thread, plus `debt_ids`: the

        tenant's debts whose contact carries the consumer's number (matched by

        blind index inside the tenant), at most 200, ordered by id. An empty

        `debt_ids` means no debt has that number - it is never "no filter".
        Unlike

        the list there is no window: the last message is the thread's last

        original, so an old thread still opens; `unread` uses the trailing 31

        days (after the last `read` event). Full bodies are served only by
        `/v1/sms/messages/{id}`; list the

        thread's rows with `/v1/sms/messages?thread_id=`.
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: The thread.
          content:
            application/json:
              schema:
                type: object
                required:
                  - sms_thread
                properties:
                  sms_thread:
                    $ref: "#/components/schemas/SmsThreadDetail"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/sms/threads/{id}/assign:
    post:
      operationId: assignSmsThread
      tags:
        - sms
      summary: Assign an SMS inbox thread to a user, or release it
      description: >
        Writes ONE append-only event (`assigned`, or `unassigned` when `user_id`

        is null) signed by the calling login; the state is never overwritten.

        `assigned_to` in `/v1/sms/threads` is the `user_id` of the thread's last

        assigned/unassigned event. Assigning again, even to the same user,
        writes

        another event.


        Needs a dashboard login (`ddu_`): a `dd_` key or an agent token has no

        person to sign and is `403 role_forbidden`. A `member` may assign the

        thread only to itself and release only a thread it holds (`403

        role_forbidden` otherwise); an `account_admin` may assign it to any

        ACTIVE user of the same tenant. A user of another tenant, an unknown

        user and a `disabled` user are all `422 invalid_assignee`. The body must

        carry `user_id` (a UUID, or null to release); omitting it is `400`.
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - user_id
              properties:
                user_id:
                  oneOf:
                    - type: string
                      format: uuid
                    - type: "null"
                  description: User to assign the thread to; null releases it.
      responses:
        "201":
          description: The recorded event.
          content:
            application/json:
              schema:
                type: object
                required:
                  - sms_thread_event
                properties:
                  sms_thread_event:
                    $ref: "#/components/schemas/SmsThreadEvent"
        "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/ApiKeyRateLimited"
  /v1/sms/threads/{id}/read:
    post:
      operationId: readSmsThread
      tags:
        - sms
      summary: Mark an SMS inbox thread read
      description: |
        Writes ONE append-only `read` event signed by the calling login (no
        body). `unread` in `/v1/sms/threads` then counts only inbound messages
        that arrived after the thread's last `read` event. Any dashboard login
        of the tenant may do it; a `dd_` key or an agent token is `403
        role_forbidden`.
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "201":
          description: The recorded event.
          content:
            application/json:
              schema:
                type: object
                required:
                  - sms_thread_event
                properties:
                  sms_thread_event:
                    $ref: "#/components/schemas/SmsThreadEvent"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /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"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
    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. The `mock` adapter (test/e2e harness) is
        refused

        with **400** unless the deployment allows it. Retire-only — no DELETE
        route exists.


        A `test` API key (core#2965) is refused with `403 test_environment`: it
        does not create, change,

        retire or provision the real trunks, providers or routes of the tenant.
      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
                default_from_e164:
                  oneOf:
                    - type: string
                      pattern: ^\+[1-9][0-9]{6,14}$
                    - type: "null"
                  description: Sender line used by `POST /v1/sms/messages` when the request has no
                    `from`. E.164; anything else is `422`. `null` = none
                    declared.
                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`.
              additionalProperties: false
            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"
        "422":
          description: >
            `default_from_e164` of a sede-scoped provider is not E.164 (`code:
            sms_from_invalid`)

            or not an ACTIVE number of the tenant (`code: sms_from_not_owned`;
            per-tenant knob

            `sms_from_ownership_enforced`, default `true`, core#2329). An
            empresa-wide row is

            not checked at write time: each send checks it against its own sede.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /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"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
    patch:
      operationId: updateSmsProvider
      tags:
        - sms-providers
      summary: Update a provider (scope immutable)
      description: >
        Editing an empresa-wide row (`scope: "empresa"`, inherited by every

        sede of the account) takes an `account_admin` session, as creating it

        does: a `member` or a machine key gets **403**. Switching to the `mock`

        adapter (test/e2e harness) is refused with **400** unless the deployment

        allows it.


        A `test` API key (core#2965) is refused with `403 test_environment`: it
        does not create, change,

        retire or provision the real trunks, providers or routes of the tenant.
      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
                default_from_e164:
                  oneOf:
                    - type: string
                      pattern: ^\+[1-9][0-9]{6,14}$
                    - type: "null"
                  description: Sender line for sends without `from`; `null` clears it.
                webhook_secret:
                  type: string
                  description: Write-only.
                status:
                  type: string
                  enum:
                    - active
                    - disabled
                    - retired
              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"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          description: >
            `default_from_e164` of a sede-scoped provider is not E.164 (`code:
            sms_from_invalid`)

            or not an ACTIVE number of the tenant (`code: sms_from_not_owned`;
            per-tenant knob

            `sms_from_ownership_enforced`, default `true`, core#2329).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/sms/providers/{id}/retire:
    post:
      operationId: retireSmsProvider
      tags:
        - sms-providers
      summary: Retire a provider (rows never delete)
      description: >
        Retiring an empresa-wide row (`scope: "empresa"`) takes an

        `account_admin` session: a `member` or a machine key gets **403**.


        A `test` API key (core#2965) is refused with `403 test_environment`: it
        does not create, change,

        retire or provision the real trunks, providers or routes of the tenant.
      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"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/messaging/brands:
    post:
      operationId: createMessagingBrand
      tags:
        - messaging
      summary: Register a 10DLC brand with the SMS provider
      description: >
        Registers the tenant's brand with The Campaign Registry through the SMS
        provider and stores

        its status: `pending`, `approved`, or `rejected` with
        `rejection_reason`. The EIN travels to

        the provider and is **not stored**; the answer carries `ein_last4`. The
        provider's brand

        fee (and the vetting fee, when `vetting_requested`) is loaded to the
        balance at cost as

        `messaging_fee` ledger lines. `registry` says whether the registration
        was `mock`

        (simulated: nothing registered, nothing charged) or `telnyx` (real,
        gated by the flag

        `messaging_10dlc_live`, off by default). **503** `fee_schedule_unset` if
        the real provider

        has no fee schedule configured; nothing is registered then. **422**
        `vetting_unsupported`

        for `vetting_requested: true` with the real provider: the external
        vetting order is not

        implemented yet, so it is refused before any call or charge (the
        simulated registry accepts it).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - legal_name
                - display_name
                - entity_type
                - vertical
                - email
                - phone
                - street
                - city
                - state
                - postal_code
              properties:
                legal_name:
                  type: string
                display_name:
                  type: string
                entity_type:
                  type: string
                  enum:
                    - private_profit
                    - public_profit
                    - non_profit
                    - government
                    - sole_proprietor
                ein:
                  type: string
                  description: 9 digits; write-only, never stored in full.
                vertical:
                  type: string
                website:
                  type: string
                email:
                  type: string
                phone:
                  type: string
                  pattern: ^\+[1-9][0-9]{6,14}$
                street:
                  type: string
                city:
                  type: string
                state:
                  type: string
                postal_code:
                  type: string
                country:
                  type: string
                  default: US
                vetting_requested:
                  type: boolean
                  default: false
              additionalProperties: false
      responses:
        "201":
          description: The registered brand.
          content:
            application/json:
              schema:
                type: object
                required:
                  - brand
                properties:
                  brand:
                    $ref: "#/components/schemas/MessagingBrand"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "422":
          $ref: "#/components/responses/Unprocessable"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
        "502":
          $ref: "#/components/responses/BadGateway"
    get:
      operationId: listMessagingBrands
      tags:
        - messaging
      summary: List the tenant's 10DLC brands
      responses:
        "200":
          description: The brands, newest first.
          content:
            application/json:
              schema:
                type: object
                required:
                  - brands
                properties:
                  brands:
                    type: array
                    items:
                      $ref: "#/components/schemas/MessagingBrand"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/messaging/brands/{id}:
    get:
      operationId: getMessagingBrand
      tags:
        - messaging
      summary: Get a 10DLC brand
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: The brand.
          content:
            application/json:
              schema:
                type: object
                required:
                  - brand
                properties:
                  brand:
                    $ref: "#/components/schemas/MessagingBrand"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/messaging/brands/{id}/refresh:
    post:
      operationId: refreshMessagingBrand
      tags:
        - messaging
      summary: Ask the provider for the current status of a pending brand
      description: A brand that is no longer `pending` is returned as it is; the
        provider is not called.
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: The brand with its current status.
          content:
            application/json:
              schema:
                type: object
                required:
                  - brand
                properties:
                  brand:
                    $ref: "#/components/schemas/MessagingBrand"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
        "502":
          $ref: "#/components/responses/BadGateway"
  /v1/messaging/campaigns:
    post:
      operationId: createMessagingCampaign
      tags:
        - messaging
      summary: Register a 10DLC campaign for an approved brand
      description: >
        Registers a campaign for a brand whose status is `approved` (**409**
        `brand_not_approved`

        otherwise) and stores its status. The provider's monthly campaign fee
        for the first month

        is loaded to the balance at cost as a `messaging_fee` line; later months
        are billed

        exactly once per month. A text from a US local number leaves only
        through a number

        assigned to an `approved` campaign (`422` `sms_10dlc_campaign_required`
        on

        `POST /v1/sms/messages` otherwise).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - brand_id
                - use_case
                - description
                - message_flow
                - sample_messages
              properties:
                brand_id:
                  type: string
                  format: uuid
                use_case:
                  type: string
                description:
                  type: string
                message_flow:
                  type: string
                sample_messages:
                  type: array
                  minItems: 1
                  maxItems: 5
                  items:
                    type: string
                help_message:
                  type: string
                optin_keywords:
                  type: string
                optout_keywords:
                  type: string
                  default: STOP
              additionalProperties: false
      responses:
        "201":
          description: The registered campaign.
          content:
            application/json:
              schema:
                type: object
                required:
                  - campaign
                properties:
                  campaign:
                    $ref: "#/components/schemas/MessagingCampaign"
        "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/ApiKeyRateLimited"
        "502":
          $ref: "#/components/responses/BadGateway"
    get:
      operationId: listMessagingCampaigns
      tags:
        - messaging
      summary: List the tenant's 10DLC campaigns
      responses:
        "200":
          description: The campaigns, newest first.
          content:
            application/json:
              schema:
                type: object
                required:
                  - campaigns
                properties:
                  campaigns:
                    type: array
                    items:
                      $ref: "#/components/schemas/MessagingCampaign"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/messaging/campaigns/{id}:
    get:
      operationId: getMessagingCampaign
      tags:
        - messaging
      summary: Get a 10DLC campaign
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: The campaign.
          content:
            application/json:
              schema:
                type: object
                required:
                  - campaign
                properties:
                  campaign:
                    $ref: "#/components/schemas/MessagingCampaign"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/messaging/campaigns/{id}/refresh:
    post:
      operationId: refreshMessagingCampaign
      tags:
        - messaging
      summary: Ask the provider for the current status of a pending campaign
      description: A campaign that is no longer `pending` is returned as it is; the
        provider is not called.
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: The campaign with its current status.
          content:
            application/json:
              schema:
                type: object
                required:
                  - campaign
                properties:
                  campaign:
                    $ref: "#/components/schemas/MessagingCampaign"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
        "502":
          $ref: "#/components/responses/BadGateway"
  /v1/messaging/campaigns/{id}/numbers:
    post:
      operationId: assignMessagingCampaignNumber
      tags:
        - messaging
      summary: Assign a number to an approved campaign
      description: >
        The number must be an active number of the tenant (**422**
        `number_not_owned`) and the

        campaign `approved` (**409** `campaign_not_approved`). Repeating the
        same assignment returns

        the existing row; a number of another campaign is **409**
        `number_assigned_elsewhere`.
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - e164
              properties:
                e164:
                  type: string
                  pattern: ^\+[1-9][0-9]{6,14}$
              additionalProperties: false
      responses:
        "201":
          description: The assignment.
          content:
            application/json:
              schema:
                type: object
                required:
                  - number
                properties:
                  number:
                    $ref: "#/components/schemas/MessagingCampaignNumber"
        "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/ApiKeyRateLimited"
        "502":
          $ref: "#/components/responses/BadGateway"
    get:
      operationId: listMessagingCampaignNumbers
      tags:
        - messaging
      summary: List the numbers of a campaign
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: The numbers.
          content:
            application/json:
              schema:
                type: object
                required:
                  - numbers
                properties:
                  numbers:
                    type: array
                    items:
                      $ref: "#/components/schemas/MessagingCampaignNumber"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/email/addresses:
    post:
      operationId: createEmailAddress
      tags:
        - email
      summary: Record a debtor email address WITH its provenance
      description: >
        Stores one address of a contact (`contact_id`) with the provenance that
        makes it

        usable or not (12 CFR 1006.6(d)(4)): `consumer_replied`,
        `consumer_consent`,

        `creditor_notice` (needs `notice_sent_at`; sendable only 35 days after
        it and

        for a public mail domain) or `prior_collector` need non-blank

        `provenance_evidence`; `imported_unverified` is stored but NEVER
        sendable.

        `sendable` / `ineligible_reason` in the answer are computed at read time

        (provenance, notice period, suppression ledger) — no field of the
        request can

        declare an address sendable. The address is normalized (trimmed,
        lower-cased);

        recording the same address twice for a contact is `409`, an unknown
        contact `400`.

        Needs the tenant scope (a `dd_` key or a dashboard login).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - contact_id
                - address
                - provenance
                - obtained_at
              properties:
                contact_id:
                  type: string
                  format: uuid
                address:
                  type: string
                  format: email
                provenance:
                  type: string
                  enum:
                    - consumer_replied
                    - consumer_consent
                    - creditor_notice
                    - prior_collector
                    - imported_unverified
                provenance_evidence:
                  type: string
                  description: Required (non-blank) for every provenance except
                    `imported_unverified`.
                notice_sent_at:
                  type: string
                  format: date-time
                  description: Required for `creditor_notice`.
                obtained_at:
                  type: string
                  format: date-time
              additionalProperties: false
            example:
              contact_id: 00000000-0000-4000-8000-000000000001
              address: debtor@example.com
              provenance: consumer_consent
              provenance_evidence: Written consent recorded in the account file
              obtained_at: 2026-10-01T12:00:00Z
      responses:
        "201":
          description: The recorded address.
          content:
            application/json:
              schema:
                type: object
                required:
                  - email_address
                properties:
                  email_address:
                    $ref: "#/components/schemas/EmailAddress"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "409":
          $ref: "#/components/responses/Conflict"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
    get:
      operationId: listEmailAddresses
      tags:
        - email
      summary: The email addresses of one contact
      parameters:
        - name: contact_id
          in: query
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: The contact's addresses, oldest first, each with its read-time
            eligibility.
          content:
            application/json:
              schema:
                type: object
                required:
                  - email_addresses
                properties:
                  email_addresses:
                    type: array
                    items:
                      $ref: "#/components/schemas/EmailAddress"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/email/addresses/{id}:
    get:
      operationId: getEmailAddress
      tags:
        - email
      summary: One email address with its read-time eligibility
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: The address.
          content:
            application/json:
              schema:
                type: object
                required:
                  - email_address
                properties:
                  email_address:
                    $ref: "#/components/schemas/EmailAddress"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/email/suppressions:
    get:
      operationId: listEmailSuppressions
      tags:
        - email
      summary: The suppression history of one address (read only)
      description: >
        The append-only suppression ledger of ONE address, newest first, plus
        the

        resulting `status` («the last row wins»: `suppressed` when the newest
        row is a

        `suppress`, else `clear`). There is no write route: suppressions are
        written by

        the unsubscribe / bounce / complaint paths, and a `lift` needs the
        consumer's own

        provenance. The address digest is never returned. A malformed `address`
        is `400`.
      parameters:
        - name: address
          in: query
          required: true
          schema:
            type: string
            format: email
      responses:
        "200":
          description: The address's ledger rows (at most 100) and its current status.
          content:
            application/json:
              schema:
                type: object
                required:
                  - status
                  - email_suppressions
                properties:
                  status:
                    type: string
                    enum:
                      - suppressed
                      - clear
                  email_suppressions:
                    type: array
                    items:
                      $ref: "#/components/schemas/EmailSuppression"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/email/providers:
    get:
      operationId: listEmailProviders
      tags:
        - email-providers
      summary: Email provider registry
      responses:
        "200":
          description: Own rows + inherited empresa-wide rows.
          content:
            application/json:
              schema:
                type: object
                required:
                  - email_providers
                properties:
                  email_providers:
                    type: array
                    items:
                      $ref: "#/components/schemas/EmailProvider"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
    post:
      operationId: createEmailProvider
      tags:
        - email-providers
      summary: Register an email provider
      description: >
        Same conventions as `/v1/sms/providers`: `scope: "empresa"` creates an

        account-wide row and needs an `account_admin` (a `member` or a machine
        key gets

        **403**); `account_id`/`tenant_id` are injected from the session. `smtp`
        needs

        `host` (a DNS name or IPv4 literal that passes the SSRF guard,
        re-checked at every

        send), `port` 465 or 587, `username` and `secret`. `ses` (Amazon SES v2)
        needs `host` =

        `email.<aws-region>.amazonaws.com`, `username` = access key id and
        `secret` = secret access key; its

        `webhook_secret` is the SNS topic ARN the events come from. `secret` is
        WRITE-ONLY:

        responses carry `has_secret`/`has_webhook_secret`, never the secrets.
        The `log` adapter is refused unless

        the deployment allows it. There is no fallback provider: a tenant with
        no active

        row does not send. Retire-only — no DELETE route exists.


        `from_address` cannot be on a platform domain (`dialerdigital.com`,
        `dialerdigital.dev`

        or a subdomain; `EMAIL_FORBIDDEN_FROM_DOMAINS` overrides the list):
        **422**. When an

        active `smtp` row is created, a bounded DNS look-up of the sender domain
        adds

        `warnings` (`from_domain_no_dmarc`, `from_domain_no_spf`) to the
        response; it is

        advice only and never blocks the registration or the sending.


        A `test` API key (core#2965) is refused with `403 test_environment`: it
        does not create, change,

        retire or provision the real trunks, providers or routes of the tenant.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - adapter
                - region
                - name
                - from_address
              properties:
                adapter:
                  type: string
                  enum:
                    - smtp
                    - ses
                    - log
                region:
                  type: string
                  enum:
                    - us
                    - mx
                    - eu
                name:
                  type: string
                from_address:
                  type: string
                  format: email
                host:
                  type: string
                port:
                  type: integer
                  enum:
                    - 465
                    - 587
                username:
                  type: string
                secret:
                  type: string
                  description: Write-only. SMTP password.
                webhook_secret:
                  type: string
                  description: Write-only. HMAC key of the provider's signed events; responses
                    carry `has_webhook_secret`.
                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`.
              additionalProperties: false
            example:
              adapter: smtp
              region: us
              name: example-email-provider
              from_address: collections@example.com
              host: smtp.example.com
              port: 587
              username: example-user
              secret: example-smtp-password-not-a-real-one
      responses:
        "201":
          description: Registered provider (`has_secret`, never the secret).
          content:
            application/json:
              schema:
                type: object
                required:
                  - email_provider
                properties:
                  email_provider:
                    $ref: "#/components/schemas/EmailProvider"
                  warnings:
                    type: array
                    items:
                      type: string
                      enum:
                        - from_domain_no_dmarc
                        - from_domain_no_spf
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "422":
          $ref: "#/components/responses/Unprocessable"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/email/providers/{id}:
    get:
      operationId: getEmailProvider
      tags:
        - email-providers
      summary: Fetch one email provider
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: The provider.
          content:
            application/json:
              schema:
                type: object
                required:
                  - email_provider
                properties:
                  email_provider:
                    $ref: "#/components/schemas/EmailProvider"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
    patch:
      operationId: updateEmailProvider
      tags:
        - email-providers
      summary: Update an email provider (scope immutable)
      description: >
        Editing an empresa-wide row takes an `account_admin` session, as
        creating it

        does. A retired row is terminal. `scope` is not accepted here: it is
        `400`.


        A `test` API key (core#2965) is refused with `403 test_environment`: it
        does not create, change,

        retire or provision the real trunks, providers or routes of the tenant.
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                adapter:
                  type: string
                  enum:
                    - smtp
                    - ses
                    - log
                region:
                  type: string
                  enum:
                    - us
                    - mx
                    - eu
                name:
                  type: string
                from_address:
                  type: string
                  format: email
                host:
                  type: string
                port:
                  type: integer
                  enum:
                    - 465
                    - 587
                username:
                  type: string
                secret:
                  type: string
                  description: Write-only.
                webhook_secret:
                  type:
                    - string
                    - "null"
                  description: Write-only. A new value rotates the key; `null` or an empty string
                    clears it, so the row stops accepting events.
                status:
                  type: string
                  enum:
                    - active
                    - disabled
                    - retired
              additionalProperties: false
            example:
              name: postman-email-renamed
              status: disabled
      responses:
        "200":
          description: Updated provider.
          content:
            application/json:
              schema:
                type: object
                required:
                  - email_provider
                properties:
                  email_provider:
                    $ref: "#/components/schemas/EmailProvider"
                  warnings:
                    type: array
                    description: Only when `from_address` or `status` changed and the row is active.
                    items:
                      type: string
                      enum:
                        - from_domain_no_dmarc
                        - from_domain_no_spf
        "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/ApiKeyRateLimited"
  /v1/email/providers/{id}/retire:
    post:
      operationId: retireEmailProvider
      tags:
        - email-providers
      summary: Retire an email provider (rows never delete)
      description: >
        Retiring an empresa-wide row takes an `account_admin` session.


        A `test` API key (core#2965) is refused with `403 test_environment`: it
        does not create, change,

        retire or provision the real trunks, providers or routes of the tenant.
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: The retired provider.
          content:
            application/json:
              schema:
                type: object
                required:
                  - email_provider
                properties:
                  email_provider:
                    $ref: "#/components/schemas/EmailProvider"
        "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"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/email/messages:
    get:
      operationId: listEmailMessages
      tags:
        - email
      summary: Email ledger query (read only)
      description: >
        Ledger ROWS — originals AND status transitions (`corrects_id`
        distinguishes) —

        newest first, keyset-paginated like `/v1/sms/messages`. Bodies are
        40-char

        previews in lists. Sending is not part of this surface: it goes through
        the

        gated Sender (campaign `send_email`), never a route.
      parameters:
        - name: direction
          in: query
          schema:
            type: string
            enum:
              - outbound
              - inbound
        - name: status
          in: query
          schema:
            type: string
            enum:
              - queued
              - sent
              - delivered
              - deferred
              - bounced
              - complained
              - 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:
                  - email_messages
                  - next_cursor
                properties:
                  email_messages:
                    type: array
                    items:
                      $ref: "#/components/schemas/EmailMessage"
                  next_cursor:
                    oneOf:
                      - type: string
                      - type: "null"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/email/messages/{id}:
    get:
      operationId: getEmailMessage
      tags:
        - email
      summary: Collapsed single email view
      description: >
        The original's identity + CURRENT status, per-status `timestamps`,
        frozen

        `compliance_snapshot`, full `subject`/`body` and the ordered
        `transitions` rows.
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: The collapsed view.
          content:
            application/json:
              schema:
                type: object
                required:
                  - email_message
                properties:
                  email_message:
                    $ref: "#/components/schemas/EmailMessageView"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/email-templates:
    get:
      operationId: listEmailTemplates
      tags:
        - email-templates
      summary: E-mail template versions
      description: Every version of every e-mail template of the tenant, newest
        version first per `name`.
      parameters:
        - name: status
          in: query
          schema:
            type: string
            enum:
              - draft
              - approved
              - retired
        - name: name
          in: query
          schema:
            type: string
      responses:
        "200":
          description: The tenant's template versions.
          content:
            application/json:
              schema:
                type: object
                required:
                  - email_templates
                properties:
                  email_templates:
                    type: array
                    items:
                      $ref: "#/components/schemas/EmailTemplate"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
    post:
      operationId: createEmailTemplate
      tags:
        - email-templates
      summary: Create a draft (the next version when `name` already exists)
      description: >
        The `body` uses `{{variable}}` markers; every marker must be declared in

        `variables` and every declared variable used. The `subject` is STATIC
        text

        (no markers) and must pass the subject policy against the declared

        `creditor_names`: it may not name the debt, an amount or a creditor. The

        static body must already carry the unsubscribe notice and the collector

        declaration (the same rules the send enforces). A `name` that already
        has

        versions opens the next version as a new draft: an approved version is

        never edited. `tenant_id`, `version` and `status` are never body input.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/EmailTemplateInput"
            example:
              name: payment-reminder
              subject: Un mensaje importante para usted
              body: Hola {{first_name}}. Este mensaje proviene de una agencia de cobranza.
                Para dejar de recibir correos, responda BAJA.
              variables:
                - name: first_name
                  type: string
              creditor_names: []
      responses:
        "201":
          description: The new draft.
          content:
            application/json:
              schema:
                type: object
                required:
                  - email_template
                properties:
                  email_template:
                    $ref: "#/components/schemas/EmailTemplate"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "409":
          $ref: "#/components/responses/Conflict"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/email-templates/{id}:
    get:
      operationId: getEmailTemplate
      tags:
        - email-templates
      summary: One template version
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: The version.
          content:
            application/json:
              schema:
                type: object
                required:
                  - email_template
                properties:
                  email_template:
                    $ref: "#/components/schemas/EmailTemplate"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
    patch:
      operationId: updateEmailTemplateDraft
      tags:
        - email-templates
      summary: Edit a draft (`subject` / `body` / `variables` / `creditor_names`)
      description: Only a `draft` can change; an approved or retired version answers
        `409` (version it instead).
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                subject:
                  type: string
                  minLength: 1
                  maxLength: 200
                body:
                  type: string
                  minLength: 1
                  maxLength: 20000
                variables:
                  type: array
                  maxItems: 20
                  items:
                    $ref: "#/components/schemas/EmailTemplateVariable"
                creditor_names:
                  type: array
                  maxItems: 50
                  items:
                    type: string
                    maxLength: 120
      responses:
        "200":
          description: The updated draft.
          content:
            application/json:
              schema:
                type: object
                required:
                  - email_template
                properties:
                  email_template:
                    $ref: "#/components/schemas/EmailTemplate"
        "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"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/email-templates/{id}/approve:
    post:
      operationId: approveEmailTemplate
      tags:
        - email-templates
      summary: Approve a draft (account_admin only)
      description: >
        Approves the draft exactly as it stands and records who and when

        (`approved_by`, `approved_at`). Only an `account_admin` login may
        approve:

        a `member` or a `dd_` machine key gets `403`. The database enforces it
        too.

        A version that is not a draft answers `409`.
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: The approved version.
          content:
            application/json:
              schema:
                type: object
                required:
                  - email_template
                properties:
                  email_template:
                    $ref: "#/components/schemas/EmailTemplate"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/email-templates/{id}/retire:
    post:
      operationId: retireEmailTemplate
      tags:
        - email-templates
      summary: Retire a version (account_admin only; a draft is discarded by retiring
        it)
      description: >
        Terminal. A retired version can no longer be sent; an already retired
        one

        answers `409`. Only an `account_admin` login may retire (a `member` or a

        `dd_` machine key gets `403`): retiring takes an approved template out
        of

        use, so it carries the same authority as approving it.
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: The retired version.
          content:
            application/json:
              schema:
                type: object
                required:
                  - email_template
                properties:
                  email_template:
                    $ref: "#/components/schemas/EmailTemplate"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/sms-templates:
    get:
      operationId: listSmsTemplates
      tags:
        - sms-templates
      summary: SMS template versions
      description: Every version of every template of the tenant, newest version first
        per `name`.
      parameters:
        - name: status
          in: query
          schema:
            type: string
            enum:
              - draft
              - approved
              - retired
        - name: name
          in: query
          schema:
            type: string
      responses:
        "200":
          description: The tenant's template versions.
          content:
            application/json:
              schema:
                type: object
                required:
                  - sms_templates
                properties:
                  sms_templates:
                    type: array
                    items:
                      $ref: "#/components/schemas/SmsTemplate"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
    post:
      operationId: createSmsTemplate
      tags:
        - sms-templates
      summary: Create a draft (the next version when `name` already exists)
      description: >
        The body uses `{{variable}}` markers; every marker must be declared in

        `variables` and every declared variable used. The static text must
        already

        carry the opt-out notice and no carrier-prohibited term (the same rules
        the

        send enforces). Variable types: `string` (1-160 chars, no control

        characters), `number`, `date` (ISO-8601). A `name` that already has
        versions

        opens the next version as a new draft: an approved version is never
        edited.

        `tenant_id`, `version` and `status` are never body input.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SmsTemplateInput"
            example:
              name: payment-reminder
              body: Hi {{first_name}}, your balance of {{amount}} is due {{due_date}}. Reply
                STOP to opt out.
              variables:
                - name: first_name
                  type: string
                - name: amount
                  type: number
                - name: due_date
                  type: date
      responses:
        "201":
          description: The new draft.
          content:
            application/json:
              schema:
                type: object
                required:
                  - sms_template
                properties:
                  sms_template:
                    $ref: "#/components/schemas/SmsTemplate"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "409":
          $ref: "#/components/responses/Conflict"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/sms-templates/{id}:
    get:
      operationId: getSmsTemplate
      tags:
        - sms-templates
      summary: One template version
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: The version.
          content:
            application/json:
              schema:
                type: object
                required:
                  - sms_template
                properties:
                  sms_template:
                    $ref: "#/components/schemas/SmsTemplate"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
    patch:
      operationId: updateSmsTemplateDraft
      tags:
        - sms-templates
      summary: Edit a draft (`body` / `variables`)
      description: Only a `draft` can change; an approved or retired version answers
        `409` (version it instead).
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                body:
                  type: string
                variables:
                  type: array
                  items:
                    $ref: "#/components/schemas/SmsTemplateVariable"
      responses:
        "200":
          description: The updated draft.
          content:
            application/json:
              schema:
                type: object
                required:
                  - sms_template
                properties:
                  sms_template:
                    $ref: "#/components/schemas/SmsTemplate"
        "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"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/sms-templates/{id}/approve:
    post:
      operationId: approveSmsTemplate
      tags:
        - sms-templates
      summary: Approve a draft (account_admin only)
      description: >
        Approves the draft exactly as it stands and records who and when

        (`approved_by`, `approved_at`). Only an `account_admin` login may
        approve:

        a `member` or a `dd_` machine key gets `403`. The database enforces it
        too.

        A version that is not a draft answers `409`.
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: The approved version.
          content:
            application/json:
              schema:
                type: object
                required:
                  - sms_template
                properties:
                  sms_template:
                    $ref: "#/components/schemas/SmsTemplate"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/sms-templates/{id}/retire:
    post:
      operationId: retireSmsTemplate
      tags:
        - sms-templates
      summary: Retire a version (a draft is discarded by retiring it)
      description: Terminal. A retired version can no longer be sent; an already
        retired one answers `409`.
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: The retired version.
          content:
            application/json:
              schema:
                type: object
                required:
                  - sms_template
                properties:
                  sms_template:
                    $ref: "#/components/schemas/SmsTemplate"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/sms/template-policy:
    get:
      operationId: getSmsTemplatePolicy
      tags:
        - sms-templates
      summary: The tenant's "templates only" setting
      description: Off (`false`) until an account_admin turns it on.
      responses:
        "200":
          description: The current setting.
          content:
            application/json:
              schema:
                type: object
                required:
                  - sms_template_policy
                properties:
                  sms_template_policy:
                    $ref: "#/components/schemas/SmsTemplatePolicy"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
    put:
      operationId: putSmsTemplatePolicy
      tags:
        - sms-templates
      summary: Turn "templates only" on or off (account_admin only)
      description: |
        With it on, `POST /v1/sms/messages` refuses free text (`422
        sms_templates_only`) and only approved templates send. Each change is an
        append-only ledger row; a `member` or a `dd_` machine key gets `403`.
        Campaign SMS (`sms_body`) is not covered by this setting.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SmsTemplatePolicy"
            example:
              templates_only: true
      responses:
        "200":
          description: The stored setting.
          content:
            application/json:
              schema:
                type: object
                required:
                  - sms_template_policy
                properties:
                  sms_template_policy:
                    $ref: "#/components/schemas/SmsTemplatePolicy"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
  /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"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
    post:
      operationId: createInboundRoute
      tags:
        - inbound-routes
      summary: Create an inbound route
      description: >
        `e164` is immutable after creation; `tenant_id` is injected by the
        router.


        Role rules (core#2439): a `dd_` key or an `account_admin` login; a
        `member` login is `403 role_forbidden` (per-tenant knob
        `telephony_config_admin_only`, default on). The `GET` of this resource
        is not affected.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required:
                - e164
                - dest_type
              properties:
                e164:
                  type: string
                  description: "E.164 (immutable after creation). It must be a number of THIS
                    account (one registered with `/v1/dids`, or a house number
                    assigned to it, core#2414): a number that is another
                    account's or that nobody registered is `422 unprocessable`
                    (`e164 not a number of this account`). A `PATCH` that brings
                    a route back to `active` is judged the same way; a route
                    that already exists is never re-judged."
                dest_type:
                  type: string
                  enum:
                    - agent
                    - queue
                    - ivr
                    - optout
                    - ai_agent
                    - webhook
                  description: "`ivr` runs one of the tenant's inbound IVR menus (core#1319):
                    `dest_ref` is REQUIRED and must be the id of a menu
                    published with `/v1/inbound-ivr-menus` in this account and
                    not retired, or the write is `400 invalid`. Each call hears
                    the menu's version current when it connects; FreeSWITCH
                    answers it after the plan verdict, core plays the prompts,
                    and the key (or, after two silent windows, the menu's
                    `default`) sends it to a queue, an agent, the opt-out or a
                    hangup. `optout` is the opt-out number (core#1316, 47 CFR
                    64.1200(b)(3)): the calling number is added to the tenant's
                    own do-not-call list when the call connects, before anything
                    answers and whatever the tenant's plan; with an active plan
                    FreeSWITCH then plays a platform-composed confirmation
                    naming the tenant's seller (or a tone) and hangs up, with a
                    dark plan the call is refused unanswered. A caller without a
                    usable number (hidden or invalid) is refused and nothing is
                    recorded. An `optout` route is bare: a non-null `dest_ref`,
                    `recording: true` or `ai_enabled: true` in the same write
                    are refused with `400 invalid`; left out, they are set to
                    none/`false`. `agent` bridges straight to the registered
                    agent; `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 and its
                    `agent_session_journal_enabled` ON too (core#1354: with the
                    seat journal OFF the call is refused unanswered, since no
                    agent could be offered it), `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. `ai_agent` names a voice
                    AI agent of the account (core#1547, ADR #200): `ai_agent_id`
                    (a `live` agent of this account) and `queue_id` (the active
                    queue the call falls back to) are REQUIRED, or the write is
                    `400 invalid`; `ai_enabled` reads `true` and `recording`
                    `false`, both derived from the type, not chosen. The
                    admission is decided before the call is answered (core#1548,
                    ADR #200 decision 3): with the tenant's `inbound_ai_enabled`
                    flag off (the default) or any of its checks failing, the
                    call is treated as a call to the `queue_id` queue and is not
                    answered by the AI; the reason is recorded in the call's CDR
                    evidence (`compliance_snapshot.inbound_ai`). Nothing hands a
                    call to the agent yet (the fork is a later delivery of ADR
                    #200), so a call to this number is not answered by the AI.
                    `webhook` hands the call to the developer's webhook
                    (core#2864, ADR #356): `webhook_url` is REQUIRED (https on
                    443, a host that resolves only to public addresses, or `400
                    invalid`) and the route is bare — `recording: true`,
                    `dest_ref`, `queue_id`, `ai_agent_id` and `ai_enabled: true`
                    in the same write are `400 invalid`; left out, `recording`
                    is `false`. The call passes the same plan, spend and
                    capacity verdicts as any inbound call BEFORE the webhook is
                    ever contacted; the leg then waits in FreeSWITCH's
                    `dd_ivr_hold`, which answers it, and from the answer core
                    POSTs each step to `webhook_url` (`call.started`, then
                    `gather.completed` or `gather.timeout`) signed with
                    `X-DD-Signature: t=<unix>,v1=<hex(HMAC-SHA256(secret,
                    \"<t>.<body>\"))>` — the same construction as an
                    outbound-webhook endpoint's. The webhook answers `200` with
                    `{\"actions\": [...]}`: 1 to 8 actions among `say` (`text`,
                    up to 500 bytes, rendered to audio by the platform),
                    `gather` (`num_digits` 1-10, `timeout_seconds` 1-30,
                    `finish_on_key`; ends the list, its result is the NEXT
                    request) and the terminal verbs `queue` (`queue_id`),
                    `agent` (`agent_id`) and `hangup`; the list must end in a
                    `gather` or a terminal verb. Fail-closed: no answer in 3
                    seconds, a non-2xx, a body that is not valid actions, a
                    destination that is not this account's, or more than 20
                    requests per call hang the call up (`NORMAL_CLEARING`);
                    nothing is ever routed to a human or a queue the webhook did
                    not choose. Every step is recorded in an append-only, signed
                    stream. A route saved without its URL is refused at connect
                    (`refused_webhook_unavailable`). A webhook route does not
                    record."
                dest_ref:
                  type: string
                  format: uuid
                  description: Required for `agent` (the agent id) and for `ivr` (the id of a
                    published, non-retired inbound IVR menu of this account,
                    else `400 invalid`); a non-null value is refused on an
                    `optout` route. A value that is not a canonical UUID is
                    refused with `400 invalid` (`dest_ref must be a UUID`,
                    core#1338).
                queue_id:
                  type: string
                  format: uuid
                  description: "The queue of a `queue` route (core#1347) or the fallback queue of
                    an `ai_agent` route (core#1547): an ACTIVE queue of this
                    account created with `/v1/inbound-queues`; a retired one,
                    another account's, one that does not exist or a malformed id
                    is `400 invalid`. REQUIRED on an `ai_agent` route
                    (core#1547): the queue the call falls back to, held to the
                    same rule. Refused on any other `dest_type`. Optional on a
                    `queue` route: without it a `queue` route is accepted, as
                    before, and refuses its calls at connect. A PATCH that moves
                    the route to another type without naming it drops it."
                ai_agent_id:
                  type: string
                  format: uuid
                  description: "REQUIRED on an `ai_agent` route (core#1547, ADR #200): the id of a
                    `live` AI agent of this account, created with
                    `/v1/ai/agents`; a draft, paused or retired one, another
                    account's, one that does not exist or a malformed id is `400
                    invalid`. Refused on any other `dest_type`; a PATCH that
                    moves the route to another type without naming it drops it.
                    The agent is judged AT WRITE (a PATCH that points the route
                    at an agent or brings it back to `active`): pausing it later
                    does not touch the route, and retiring it while an active
                    route names it is `409` on `PATCH /v1/ai/agents/{id}` and
                    `DELETE /v1/ai/agents/{id}`."
                hours:
                  oneOf:
                    - $ref: "#/components/schemas/InboundRouteHours"
                    - type: "null"
                  description: >
                    The route's hours (core#2530, ADR #304). Absent or `null` =
                    the number answers at all hours,

                    exactly as before; on a `PATCH`, `null` REMOVES the hours
                    and leaving the key out keeps them.

                    Outside the hours, or on a holiday, a NEW call is treated as
                    the route `closed` names (a message

                    or another destination); a call already admitted is never
                    cut when the closing time passes. An

                    `optout` route has no hours: sending them with `dest_type:
                    optout`, or moving a route that has

                    them to `optout` without `hours: null` in the same write, is
                    `422`. A zone that does not

                    resolve, a malformed or repeated holiday, more than 366
                    holidays, a `closed` that is not exactly

                    one of its two shapes, or one that names a queue, menu,
                    agent or AI agent that is not live in this

                    account is `422 unprocessable`. The `closed` message is
                    rendered to audio when the hours are

                    saved; if it cannot be, the write is `503
                    prompt_render_failed` and nothing is saved.

                    A route with hours is provisioned anchored on FreeSWITCH (an
                    `agent` route without `recording`

                    is otherwise delivered straight to the agent and core never
                    sees the call): after writing the

                    hours, `POST /v1/inbound-routes/{id}/provision` so the SIP
                    edge learns it.
                webhook_url:
                  type: string
                  format: uri
                  maxLength: 2048
                  description: >
                    REQUIRED on a `webhook` route (core#2864, ADR #356): the
                    developer's https URL on port 443 whose

                    host resolves only to public addresses (no loopback,
                    private, link-local or metadata range), or

                    `400 invalid`; refused on any other `dest_type` (a PATCH
                    that moves the route off `webhook`

                    without naming it drops it). The route's signing secret
                    (`whsec_…`) is minted when the route is

                    created as `webhook`, when a write moves it to `webhook` and
                    whenever a write NAMES `webhook_url`

                    (a new URL is a new receiver, it never inherits the old
                    key); it is returned ONCE, as

                    `webhook_secret` in that write's response, and afterwards
                    only `webhook_secret_hint` is readable.
                recording:
                  type: boolean
                  description: "`true` is refused on an `optout` route, on a `webhook` route and
                    on an `ai_agent` route (the AI leg is not recorded, ADR
                    #200); omitted there, it is `false`. Default `true` on every
                    other route."
                ai_enabled:
                  type: boolean
                  description: "Derived from the destination, not chosen (ADR #148, ADR #200):
                    `true` if and only if `dest_type` is `ai_agent`. Naming
                    `true` on any other destination, or `false` on an `ai_agent`
                    route, is `400 invalid` (`ai_enabled is derived from the
                    destination ...`); omit it, or send the value the
                    destination implies (default `false`)."
                notes:
                  type: string
            example:
              e164: "+13125550171"
              dest_type: queue
      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"
        "422":
          $ref: "#/components/responses/Unprocessable"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
        "503":
          description: The `closed` message of the hours could not be rendered to audio
            (`prompt_render_failed`); the route was not saved. The request is
            right and may succeed once the text-to-speech answers.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
  /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"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
    patch:
      operationId: updateInboundRoute
      tags:
        - inbound-routes
      summary: Update an inbound route (e164 immutable)
      description: >
        Role rules (core#2439): a `dd_` key or an `account_admin` login; a
        `member` login is `403 role_forbidden` (per-tenant knob
        `telephony_config_admin_only`, default on). The `GET` of this resource
        is not affected.
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              properties:
                dest_type:
                  type: string
                  enum:
                    - agent
                    - queue
                    - ivr
                    - optout
                    - ai_agent
                    - webhook
                  description: "`ivr` runs one of the tenant's inbound IVR menus (core#1319):
                    `dest_ref` is REQUIRED and must be the id of a menu
                    published with `/v1/inbound-ivr-menus` in this account and
                    not retired, or the write is `400 invalid`. Each call hears
                    the menu's version current when it connects; FreeSWITCH
                    answers it after the plan verdict, core plays the prompts,
                    and the key (or, after two silent windows, the menu's
                    `default`) sends it to a queue, an agent, the opt-out or a
                    hangup. `optout` is the opt-out number (core#1316, 47 CFR
                    64.1200(b)(3)): the calling number is added to the tenant's
                    own do-not-call list when the call connects, before anything
                    answers and whatever the tenant's plan; with an active plan
                    FreeSWITCH then plays a platform-composed confirmation
                    naming the tenant's seller (or a tone) and hangs up, with a
                    dark plan the call is refused unanswered. A caller without a
                    usable number (hidden or invalid) is refused and nothing is
                    recorded. An `optout` route is bare: a non-null `dest_ref`,
                    `recording: true` or `ai_enabled: true` in the same write
                    are refused with `400 invalid`; left out, they are set to
                    none/`false`. `agent` bridges straight to the registered
                    agent; `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 and its
                    `agent_session_journal_enabled` ON too (core#1354: with the
                    seat journal OFF the call is refused unanswered, since no
                    agent could be offered it), `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. `ai_agent` names a voice
                    AI agent of the account (core#1547, ADR #200): `ai_agent_id`
                    (a `live` agent of this account) and `queue_id` (the active
                    queue the call falls back to) are REQUIRED, or the write is
                    `400 invalid`; `ai_enabled` reads `true` and `recording`
                    `false`, both derived from the type, not chosen. The
                    admission is decided before the call is answered (core#1548,
                    ADR #200 decision 3): with the tenant's `inbound_ai_enabled`
                    flag off (the default) or any of its checks failing, the
                    call is treated as a call to the `queue_id` queue and is not
                    answered by the AI; the reason is recorded in the call's CDR
                    evidence (`compliance_snapshot.inbound_ai`). Nothing hands a
                    call to the agent yet (the fork is a later delivery of ADR
                    #200), so a call to this number is not answered by the AI.
                    `webhook` hands the call to the developer's webhook
                    (core#2864, ADR #356): `webhook_url` is REQUIRED (https on
                    443, a host that resolves only to public addresses, or `400
                    invalid`) and the route is bare — `recording: true`,
                    `dest_ref`, `queue_id`, `ai_agent_id` and `ai_enabled: true`
                    in the same write are `400 invalid`; left out, `recording`
                    is `false`. The call passes the same plan, spend and
                    capacity verdicts as any inbound call BEFORE the webhook is
                    ever contacted; the leg then waits in FreeSWITCH's
                    `dd_ivr_hold`, which answers it, and from the answer core
                    POSTs each step to `webhook_url` (`call.started`, then
                    `gather.completed` or `gather.timeout`) signed with
                    `X-DD-Signature: t=<unix>,v1=<hex(HMAC-SHA256(secret,
                    \"<t>.<body>\"))>` — the same construction as an
                    outbound-webhook endpoint's. The webhook answers `200` with
                    `{\"actions\": [...]}`: 1 to 8 actions among `say` (`text`,
                    up to 500 bytes, rendered to audio by the platform),
                    `gather` (`num_digits` 1-10, `timeout_seconds` 1-30,
                    `finish_on_key`; ends the list, its result is the NEXT
                    request) and the terminal verbs `queue` (`queue_id`),
                    `agent` (`agent_id`) and `hangup`; the list must end in a
                    `gather` or a terminal verb. Fail-closed: no answer in 3
                    seconds, a non-2xx, a body that is not valid actions, a
                    destination that is not this account's, or more than 20
                    requests per call hang the call up (`NORMAL_CLEARING`);
                    nothing is ever routed to a human or a queue the webhook did
                    not choose. Every step is recorded in an append-only, signed
                    stream. A route saved without its URL is refused at connect
                    (`refused_webhook_unavailable`). A webhook route does not
                    record."
                dest_ref:
                  type: string
                  format: uuid
                  description: Required for `agent` (the agent id) and for `ivr` (the id of a
                    published, non-retired inbound IVR menu of this account,
                    else `400 invalid`); a non-null value is refused on an
                    `optout` route. A value that is not a canonical UUID is
                    refused with `400 invalid` (`dest_ref must be a UUID`,
                    core#1338).
                queue_id:
                  type: string
                  format: uuid
                  description: "The queue of a `queue` route (core#1347) or the fallback queue of
                    an `ai_agent` route (core#1547): an ACTIVE queue of this
                    account created with `/v1/inbound-queues`; a retired one,
                    another account's, one that does not exist or a malformed id
                    is `400 invalid`. REQUIRED on an `ai_agent` route
                    (core#1547): the queue the call falls back to, held to the
                    same rule. Refused on any other `dest_type`. Optional on a
                    `queue` route: without it a `queue` route is accepted, as
                    before, and refuses its calls at connect. A PATCH that moves
                    the route to another type without naming it drops it."
                ai_agent_id:
                  type: string
                  format: uuid
                  description: "REQUIRED on an `ai_agent` route (core#1547, ADR #200): the id of a
                    `live` AI agent of this account, created with
                    `/v1/ai/agents`; a draft, paused or retired one, another
                    account's, one that does not exist or a malformed id is `400
                    invalid`. Refused on any other `dest_type`; a PATCH that
                    moves the route to another type without naming it drops it.
                    The agent is judged AT WRITE (a PATCH that points the route
                    at an agent or brings it back to `active`): pausing it later
                    does not touch the route, and retiring it while an active
                    route names it is `409` on `PATCH /v1/ai/agents/{id}` and
                    `DELETE /v1/ai/agents/{id}`."
                hours:
                  oneOf:
                    - $ref: "#/components/schemas/InboundRouteHours"
                    - type: "null"
                  description: >
                    The route's hours (core#2530, ADR #304). Absent or `null` =
                    the number answers at all hours,

                    exactly as before; on a `PATCH`, `null` REMOVES the hours
                    and leaving the key out keeps them.

                    Outside the hours, or on a holiday, a NEW call is treated as
                    the route `closed` names (a message

                    or another destination); a call already admitted is never
                    cut when the closing time passes. An

                    `optout` route has no hours: sending them with `dest_type:
                    optout`, or moving a route that has

                    them to `optout` without `hours: null` in the same write, is
                    `422`. A zone that does not

                    resolve, a malformed or repeated holiday, more than 366
                    holidays, a `closed` that is not exactly

                    one of its two shapes, or one that names a queue, menu,
                    agent or AI agent that is not live in this

                    account is `422 unprocessable`. The `closed` message is
                    rendered to audio when the hours are

                    saved; if it cannot be, the write is `503
                    prompt_render_failed` and nothing is saved.

                    A route with hours is provisioned anchored on FreeSWITCH (an
                    `agent` route without `recording`

                    is otherwise delivered straight to the agent and core never
                    sees the call): after writing the

                    hours, `POST /v1/inbound-routes/{id}/provision` so the SIP
                    edge learns it.
                webhook_url:
                  type: string
                  format: uri
                  maxLength: 2048
                  description: >
                    REQUIRED on a `webhook` route (core#2864, ADR #356): the
                    developer's https URL on port 443 whose

                    host resolves only to public addresses (no loopback,
                    private, link-local or metadata range), or

                    `400 invalid`; refused on any other `dest_type` (a PATCH
                    that moves the route off `webhook`

                    without naming it drops it). The route's signing secret
                    (`whsec_…`) is minted when the route is

                    created as `webhook`, when a write moves it to `webhook` and
                    whenever a write NAMES `webhook_url`

                    (a new URL is a new receiver, it never inherits the old
                    key); it is returned ONCE, as

                    `webhook_secret` in that write's response, and afterwards
                    only `webhook_secret_hint` is readable.
                recording:
                  type: boolean
                  description: "`true` is refused on an `optout` route, on a `webhook` route and
                    on an `ai_agent` route (the AI leg is not recorded, ADR
                    #200); omitted there, it is `false`. Default `true` on every
                    other route."
                ai_enabled:
                  type: boolean
                  description: "Derived from the destination, not chosen (ADR #148, ADR #200):
                    `true` if and only if `dest_type` is `ai_agent`. Naming
                    `true` on any other destination, or `false` on an `ai_agent`
                    route, is `400 invalid` (`ai_enabled is derived from the
                    destination ...`); omit it, or send the value the
                    destination implies (default `false`)."
                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"
        "422":
          $ref: "#/components/responses/Unprocessable"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
        "503":
          description: The `closed` message of the hours could not be rendered to audio
            (`prompt_render_failed`); the route was not saved. The request is
            right and may succeed once the text-to-speech answers.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
  /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.


        Role rules (core#2439): a `dd_` key or an `account_admin` login; a
        `member` login is `403 role_forbidden` (per-tenant knob
        `telephony_config_admin_only`, default on). The `GET` of this resource
        is not affected.


        A `test` API key (core#2965) is refused with `403 test_environment`: it
        does not create, change,

        retire or provision the real trunks, providers or routes of the tenant.
      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"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
        "502":
          $ref: "#/components/responses/SbcError"
  /v1/inbound-routes/{id}/disable:
    post:
      operationId: disableInboundRoute
      tags:
        - inbound-routes
      summary: Disable an inbound route
      description: >
        Role rules (core#2439): a `dd_` key or an `account_admin` login; a
        `member` login is `403 role_forbidden` (per-tenant knob
        `telephony_config_admin_only`, default on). The `GET` of this resource
        is not affected.
      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"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/inbound-ivr-menus:
    get:
      operationId: listInboundIvrMenus
      tags:
        - inbound-ivr-menus
      summary: The tenant's current inbound IVR menus
      description: |
        Every menu that is not retired, in its current (highest) version,
        ordered by name. A retired menu is not listed; `GET
        /v1/inbound-ivr-menus/{id}` still answers it. Readable by a `dd_` key
        and by any dashboard login; a widget token (`ddw_`) is `403
        forbidden`.

        An inbound route with `dest_type: ivr` names one of these menus in its
        `dest_ref`; each call to it hears the version current when it
        connects (core#1319).
      responses:
        "200":
          description: The current menus.
          content:
            application/json:
              schema:
                type: object
                required:
                  - inbound_ivr_menus
                properties:
                  inbound_ivr_menus:
                    type: array
                    items:
                      $ref: "#/components/schemas/InboundIvrMenu"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
    post:
      operationId: publishInboundIvrMenu
      tags:
        - inbound-ivr-menus
      summary: Publish an inbound IVR menu, or the next version of one
      description: |
        Publishes `menu` under `name`. Without `menu_id` it is a new menu,
        version 1, whose id is the menu's from then on; with `menu_id` it is
        the next version of that menu, which becomes the current one. Nothing
        is ever edited in place: the history keeps every version.

        Publishing, in order, and nothing is written unless all pass:

        1. the tree is validated (`InboundIvrMenuNode`): every node has a
           prompt and a `default` action, keys are single DTMF keys, at most
           5 levels and 64 nodes;
        2. every `queue:<id>` must be an ACTIVE (not retired) inbound queue
           of this tenant (`/v1/inbound-queues`) and every `agent:<id>` an
           ACTIVE agent of this tenant, else `422` naming them (another
           tenant's, a non-existent one and a retired queue read the same);
        3. every prompt is rendered to audio NOW, not when a call arrives. If
           one does not render the menu is not published: `503
           prompt_render_failed`, and the same request may be retried.

        The prompts are the tenant's words: the platform adds no text of its
        own. A `menu_id` that is not a menu of this tenant is `404`; a retired
        one is `409 conflict` (a retired menu stays retired — publish a new
        one).

        **Tenant management only**: a `dd_` key or an `account_admin` login.
        A `member` login is `403 role_forbidden`; a widget token (`ddw_`) is
        `403 forbidden`. Who published is taken from the credential.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required:
                - name
                - menu
              properties:
                name:
                  type: string
                  minLength: 1
                  maxLength: 120
                  description: The menu's name, for the operator (trimmed; blank is refused).
                menu:
                  $ref: "#/components/schemas/InboundIvrMenuNode"
                menu_id:
                  type: string
                  format: uuid
                  description: The menu to publish the next version of. Absent ⇒ a new menu.
            example:
              name: Main line
              menu:
                prompt: Thanks for calling. For sales press 1. For billing press 2. To stop
                  receiving our calls press 9.
                default: queue:6f1c1b7e-2d7a-4c1e-9d3a-0b5e8f7a1c21
                entries:
                  "1":
                    action: agent:0d9e2b4a-8c3f-4e6b-a1d2-3c4b5a6f7e80
                  "2":
                    submenu:
                      prompt: For a copy of your invoice press 1.
                      default: hangup
                      entries:
                        "1":
                          action: queue:6f1c1b7e-2d7a-4c1e-9d3a-0b5e8f7a1c21
                  "9":
                    action: optout
      responses:
        "201":
          description: Published.
          content:
            application/json:
              schema:
                type: object
                required:
                  - inbound_ivr_menu
                properties:
                  inbound_ivr_menu:
                    $ref: "#/components/schemas/InboundIvrMenu"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: |
            `role_forbidden`: a dashboard login without the `account_admin`
            role; `forbidden`: a widget token or a suspended tenant.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: role_forbidden
                  message: a dashboard login needs the account_admin role to manage inbound IVR
                    menus
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "422":
          $ref: "#/components/responses/Unprocessable"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
        "503":
          description: A prompt did not render to audio; nothing was published. Retryable.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: prompt_render_failed
                  message: a prompt could not be rendered to audio; the menu was not published
  /v1/inbound-ivr-menus/{id}:
    get:
      operationId: getInboundIvrMenu
      tags:
        - inbound-ivr-menus
      summary: One inbound IVR menu, its current version and its history
      description: |
        The menu's current version — or, once retired, the version it was
        retired at, with `status: retired` — plus `history`, every fact from
        the first version to the retirement. A menu that is not this tenant's
        (or does not exist) is `404`. Same readers as the list.
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: The menu and its history.
          content:
            application/json:
              schema:
                type: object
                required:
                  - inbound_ivr_menu
                properties:
                  inbound_ivr_menu:
                    allOf:
                      - $ref: "#/components/schemas/InboundIvrMenu"
                      - type: object
                        required:
                          - history
                        properties:
                          history:
                            type: array
                            items:
                              $ref: "#/components/schemas/InboundIvrMenuFact"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/inbound-ivr-menus/{id}/retire:
    post:
      operationId: retireInboundIvrMenu
      tags:
        - inbound-ivr-menus
      summary: Retire an inbound IVR menu
      description: |
        Retires the menu with a new fact that names its current version; the
        history is append-only. A retired menu stays retired: it cannot be
        published again. An id that is not a current menu of this tenant
        (unknown, already retired, or another tenant's) is `404`. A menu that
        an ACTIVE inbound route still uses is `409 conflict` naming the routes:
        disable them or point them at another menu first (core#1319). Same
        roles as publishing. No body.
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: Retired.
          content:
            application/json:
              schema:
                type: object
                required:
                  - inbound_ivr_menu
                properties:
                  inbound_ivr_menu:
                    $ref: "#/components/schemas/InboundIvrMenu"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: |
            `role_forbidden`: a dashboard login without the `account_admin`
            role; `forbidden`: a widget token or a suspended tenant.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/inbound-queues:
    get:
      operationId: listInboundQueues
      tags:
        - inbound-queues
      summary: The tenant's active inbound queues, and whether queuing is on
      description: |
        Every queue that is not retired, ordered by name (case-insensitive;
        a queue created before names existed has `name: null` and comes
        last). Each carries its overflow and how many ACTIVE `queue` routes
        name it. A retired queue is not listed; `GET
        /v1/inbound-queues/{id}` still answers it.

        `capability` says whether the inbound queue admits calls for this
        tenant, and which switch is missing when it does not (ADR #104 D7
        and its core#1354 addendum): it takes TWO switches, both resolved
        for the tenant — `inbound_queue_stream_enabled`, the queue's own, and
        `agent_session_journal_enabled`, the agent seat journal without which
        no agent can be offered a call. `enabled` is `true` only with both
        ON; with either OFF, a call to a `queue` route is not admitted to any
        queue, is not answered and nobody is offered it, whatever the queue's
        configuration. This read changes neither switch. Readable by a
        `dd_` key and by any dashboard login; a widget token (`ddw_`) is `403
        forbidden`.
      responses:
        "200":
          description: The active queues and the capability.
          content:
            application/json:
              schema:
                type: object
                required:
                  - inbound_queues
                  - capability
                properties:
                  inbound_queues:
                    type: array
                    items:
                      $ref: "#/components/schemas/InboundQueue"
                  capability:
                    $ref: "#/components/schemas/InboundQueueCapability"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
    post:
      operationId: createInboundQueue
      tags:
        - inbound-queues
      summary: Create an inbound queue
      description: |
        Creates an active queue named `name`, with no overflow. The name is
        trimmed and must be unique among this tenant's ACTIVE queues without
        regard to case (`409 conflict` otherwise); a retired queue's name is
        free again. A queue is never renamed.

        **Tenant management only**: a `dd_` key or an `account_admin` login.
        A `member` login is `403 role_forbidden`; a widget token (`ddw_`) is
        `403 forbidden`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required:
                - name
              properties:
                name:
                  type: string
                  minLength: 1
                  maxLength: 80
                  description: The queue's name, for the operator (trimmed; blank is refused).
            example:
              name: Sales
      responses:
        "201":
          description: Created.
          content:
            application/json:
              schema:
                type: object
                required:
                  - inbound_queue
                properties:
                  inbound_queue:
                    $ref: "#/components/schemas/InboundQueue"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: |
            `role_forbidden`: a dashboard login without the `account_admin`
            role; `forbidden`: a widget token or a suspended tenant.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: role_forbidden
                  message: a dashboard login needs the account_admin role to manage inbound queues
        "409":
          $ref: "#/components/responses/Conflict"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/inbound-queues/live:
    get:
      operationId: listInboundQueuesLive
      tags:
        - inbound-queues
      summary: How many calls wait in each active inbound queue, and since when
      description: >
        The live state of every ACTIVE inbound queue of the tenant (core#1526):

        `depth` — the calls waiting now — and `oldest_wait_ms` — how long the

        longest-waiting one has waited. Both are DERIVED from the append-only

        queue stream, never from a counter: a call is waiting while its

        `enqueued` has no terminal (`answered`, `abandoned`, `overflowed`,

        `callback_booked`). A

        call that is being offered to an agent is still waiting and counts. A

        call that overflows to another queue stops counting here and counts in

        the destination, where its wait starts at the overflow.


        **Off is not zero.** With the tenant's `inbound_queue_stream_enabled`

        OFF nobody writes the stream, so there is nothing to count:

        `stream_enabled` is `false` and `depth` and `oldest_wait_ms` are `null`

        — never `0`. `depth: 0` means the queue is on and empty, and then

        `oldest_wait_ms` is `null` too.


        **Orphans do not inflate it.** A crash between the `enqueued` and its

        terminal leaves an admission nobody closes. An admission older than

        `wait_bound_ms` is read as orphaned and left out of both numbers:

        `wait_bound_ms` is the queue's `overflow_max_wait_ms` (the same limit

        its overflow uses) and, for a queue with no overflow configured, a

        fixed ceiling of one hour. The stream is append-only; nothing is

        closed, the read just stops counting it.


        `oldest_wait_ms` is a live read taken with the wall clock (`as_of`

        minus the `enqueued` instant, never below `0`), not the measured

        `wait_duration_ms` that history and reports use. `ready_agents` is the

        tenant-wide count of agents in Ready now (the agents are not per-queue),

        `null` when the agent seat journal is OFF. Readable by a `dd_` key and

        by any dashboard login; a widget token (`ddw_`) is `403 forbidden`.


        **VIP and skills (core#1677).** Per queue, `vip_depth` and

        `vip_oldest_wait_ms` are the same two numbers counted over the VIP calls

        only (admissions sealed `vip`). They are `null` — never `0` — while the

        VIP is not active for the tenant (`inbound_queue_vip_head_start_ms` at

        `0`, or inert because the head start reaches the service threshold) and

        while the queue stream is OFF; with the VIP active and no VIP waiting,

        `vip_depth` is `0` and `vip_oldest_wait_ms` is `null`. `ready_matching`
        is

        how many Ready agents meet THAT queue's skill requirement; it is `null`

        when the queue requires no skills, skill routing is OFF, the seat
        journal

        is OFF, or the skills could not be read. It counts the Ready agents of
        the

        journal, without discounting those already holding a live offer.
      responses:
        "200":
          description: The live state of the active queues.
          content:
            application/json:
              schema:
                type: object
                required:
                  - as_of
                  - stream_enabled
                  - ready_agents
                  - inbound_queues
                properties:
                  as_of:
                    type: string
                    format: date-time
                    description: The instant the read was taken.
                  stream_enabled:
                    type: boolean
                    description: "`false` = the queue stream is OFF for the tenant: `depth` is
                      unknown, not zero."
                  ready_agents:
                    type:
                      - integer
                      - "null"
                    minimum: 0
                    description: Agents in Ready now, tenant-wide; `null` when the seat journal is
                      OFF.
                  inbound_queues:
                    type: array
                    items:
                      $ref: "#/components/schemas/InboundQueueLive"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/inbound-queues/{id}:
    get:
      operationId: getInboundQueue
      tags:
        - inbound-queues
      summary: One inbound queue, active or retired
      description: |
        The queue — with `status: retired` once retired — and the tenant's
        `capability`, as in the list. A queue that is not this tenant's (or
        does not exist) is `404`. Same readers as the list.
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: The queue.
          content:
            application/json:
              schema:
                type: object
                required:
                  - inbound_queue
                  - capability
                properties:
                  inbound_queue:
                    $ref: "#/components/schemas/InboundQueue"
                  capability:
                    $ref: "#/components/schemas/InboundQueueCapability"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/inbound-queues/{id}/live:
    get:
      operationId: getInboundQueueLive
      tags:
        - inbound-queues
      summary: How many calls wait in one inbound queue, and since when
      description: |
        The same live read as `GET /v1/inbound-queues/live`, for one queue
        (same rules: derived from the stream, off is `null` and not `0`,
        orphans past `wait_bound_ms` left out). A queue that is not this
        tenant's, does not exist or is retired is `404`: a retired queue admits
        nobody, so there is nothing live to read. Same readers.
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: The live state of the queue.
          content:
            application/json:
              schema:
                type: object
                required:
                  - as_of
                  - stream_enabled
                  - ready_agents
                  - inbound_queue
                properties:
                  as_of:
                    type: string
                    format: date-time
                  stream_enabled:
                    type: boolean
                  ready_agents:
                    type:
                      - integer
                      - "null"
                    minimum: 0
                  inbound_queue:
                    $ref: "#/components/schemas/InboundQueueLive"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/inbound-queues/{id}/callback-offer/stats:
    get:
      operationId: getInboundQueueCallbackOfferStats
      tags:
        - inbound-queues
      summary: How many callbacks one inbound queue offered and how many were accepted
      description: |
        The callback offers made in `[from, to)` on one queue and how each ended
        (ADR #215 addendum, core#1884). Derived from the append-only queue
        stream (`callback_offered`, `callback_booked` and the other terminal
        events); nothing is accumulated. The cohort is the offers made in the
        window, so `offered = accepted + declined_or_abandoned + open` always:

        - `accepted`: the caller took the callback (a `callback_booked` closed
          the admission), even if it happened after `to`.
        - `declined_or_abandoned`: the admission closed any other way
          (answered, abandoned or overflowed) without a booking.
        - `open`: the admission has not closed yet.

        Both `from` and `to` are required (RFC-3339). `400` when one is missing
        or malformed or `from` is not before `to`; `422` when the window is
        longer than 31 days. A queue that is not this tenant's or does not
        exist is `404`; a retired queue keeps its history readable. Same readers
        as the other inbound-queue reads.
      parameters:
        - $ref: "#/components/parameters/PathId"
        - name: from
          in: query
          required: true
          description: Start of the window, inclusive.
          schema:
            type: string
            format: date-time
        - name: to
          in: query
          required: true
          description: End of the window, exclusive. At most 31 days after `from`.
          schema:
            type: string
            format: date-time
      responses:
        "200":
          description: The counts for the window.
          content:
            application/json:
              schema:
                type: object
                required:
                  - inbound_queue_id
                  - from
                  - to
                  - callback_offer
                properties:
                  inbound_queue_id:
                    type: string
                    format: uuid
                  from:
                    type: string
                    format: date-time
                  to:
                    type: string
                    format: date-time
                  callback_offer:
                    type: object
                    required:
                      - offered
                      - accepted
                      - declined_or_abandoned
                      - open
                    properties:
                      offered:
                        type: integer
                        minimum: 0
                      accepted:
                        type: integer
                        minimum: 0
                      declined_or_abandoned:
                        type: integer
                        minimum: 0
                      open:
                        type: integer
                        minimum: 0
              example:
                inbound_queue_id: 0b6f5f8e-3f5c-4d0e-9a53-6a2f1f6a1c11
                from: 2026-10-01T00:00:00Z
                to: 2026-10-02T00:00:00Z
                callback_offer:
                  offered: 6
                  accepted: 2
                  declined_or_abandoned: 3
                  open: 1
        "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/ApiKeyRateLimited"
  /v1/inbound-queues/{id}/overflow:
    put:
      operationId: setInboundQueueOverflow
      tags:
        - inbound-queues
      summary: Set the queue's overflow to another of the tenant's queues
      description: |
        A call that has waited `overflow_max_wait_ms` in this queue moves to
        the queue `overflow_queue_id` (ADR #104 FASE 2c: the only overflow
        there is). Both fields are required. `400 invalid` when the maximum
        is not a positive integer, when the destination is this same queue,
        is not an ACTIVE queue of this tenant (retired, another tenant's, one
        that does not exist, malformed), or already overflows — directly or
        through other queues — back to this one: an overflow cycle is
        refused. A queue that is not an active queue of this tenant is
        `404`. Same roles as creating.
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required:
                - overflow_max_wait_ms
                - overflow_queue_id
              properties:
                overflow_max_wait_ms:
                  type: integer
                  minimum: 1
                overflow_queue_id:
                  type: string
                  format: uuid
            example:
              overflow_max_wait_ms: 120000
              overflow_queue_id: 6f1c1b7e-2d7a-4c1e-9d3a-0b5e8f7a1c21
      responses:
        "200":
          description: The queue with its overflow.
          content:
            application/json:
              schema:
                type: object
                required:
                  - inbound_queue
                properties:
                  inbound_queue:
                    $ref: "#/components/schemas/InboundQueue"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: |
            `role_forbidden`: a dashboard login without the `account_admin`
            role; `forbidden`: a widget token or a suspended tenant.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
    delete:
      operationId: clearInboundQueueOverflow
      tags:
        - inbound-queues
      summary: Remove the queue's overflow
      description: |
        Both overflow fields back to `null`: a call waits in this queue as
        long as it takes. A queue that is not an active queue of this tenant
        is `404`. Same roles as creating. No body.
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: The queue without overflow.
          content:
            application/json:
              schema:
                type: object
                required:
                  - inbound_queue
                properties:
                  inbound_queue:
                    $ref: "#/components/schemas/InboundQueue"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: |
            `role_forbidden`: a dashboard login without the `account_admin`
            role; `forbidden`: a widget token or a suspended tenant.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/inbound-queues/{id}/callback-offer:
    put:
      operationId: setInboundQueueCallbackOffer
      tags:
        - inbound-queues
      summary: Set the wait after which the queue offers a callback
      description: |
        Sets `callback_offer_after_ms`, the MEASURED wait in this queue after
        which a caller would be offered a callback instead of waiting on
        (ADR #215, core#1636). The field is required. `400 invalid` when it is
        not an integer from 1 to 9223372036854775807. A queue that is not an
        active queue of this tenant is `404`. Same roles as creating.

        This only stores the threshold: nothing is offered until the account's
        `inbound_callback_offer_enabled` flag is on and the offer itself ships.
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required:
                - callback_offer_after_ms
              properties:
                callback_offer_after_ms:
                  type: integer
                  minimum: 1
            example:
              callback_offer_after_ms: 90000
      responses:
        "200":
          description: The queue with its callback-offer threshold.
          content:
            application/json:
              schema:
                type: object
                required:
                  - inbound_queue
                properties:
                  inbound_queue:
                    $ref: "#/components/schemas/InboundQueue"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: |
            `role_forbidden`: a dashboard login without the `account_admin`
            role; `forbidden`: a widget token or a suspended tenant.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
    delete:
      operationId: clearInboundQueueCallbackOffer
      tags:
        - inbound-queues
      summary: Remove the queue's callback-offer threshold
      description: |
        `callback_offer_after_ms` back to `null`: this queue offers no
        callback. A queue that is not an active queue of this tenant is
        `404`. Same roles as creating. No body.
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: The queue without a callback-offer threshold.
          content:
            application/json:
              schema:
                type: object
                required:
                  - inbound_queue
                properties:
                  inbound_queue:
                    $ref: "#/components/schemas/InboundQueue"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: |
            `role_forbidden`: a dashboard login without the `account_admin`
            role; `forbidden`: a widget token or a suspended tenant.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/inbound-queues/{id}/retire:
    post:
      operationId: retireInboundQueue
      tags:
        - inbound-queues
      summary: Retire an inbound queue
      description: |
        Retires the queue for good: it leaves the list, cannot be named by a
        route, a menu or an overflow, and cannot be configured; its history
        stays. An id that is not an active queue of this tenant (unknown,
        already retired, or another tenant's) is `404`. While something
        active still names the queue — an ACTIVE `queue` route, a current
        inbound IVR menu, or another active queue's overflow — it is `409
        conflict` naming each of them: disable or re-point them first. Same
        roles as creating. No body.
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: Retired.
          content:
            application/json:
              schema:
                type: object
                required:
                  - inbound_queue
                properties:
                  inbound_queue:
                    $ref: "#/components/schemas/InboundQueue"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: |
            `role_forbidden`: a dashboard login without the `account_admin`
            role; `forbidden`: a widget token or a suspended tenant.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/inbound-vip-lists:
    put:
      operationId: setInboundVipLists
      tags:
        - inbound-queues
      summary: Replace the debt lists designated as VIP for the inbound queue
      description: |
        Declares which of the tenant's debt lists count as VIP for the inbound
        queue (ADR #216 D9). A caller is VIP when their number belongs to a debt
        that is in any of these lists; who is in a list is managed with
        `/v1/debt-lists` and the import, nothing else changes. The body REPLACES
        the whole set; a repeated id counts once and `[]` designates nothing.
        `422 unprocessable` when `list_ids` is not an array of UUID strings,
        has more than 50, or names a list that is not a list of this tenant;
        nothing is written then. Writing works with
        `inbound_queue_vip_head_start_ms` at `0`: what is stored stays inert
        until the head start is turned on. Deleting a list retires its
        designation. Admin only: `dd_` or a dashboard login with the
        `account_admin` role.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required:
                - list_ids
              properties:
                list_ids:
                  type: array
                  maxItems: 50
                  items:
                    type: string
                    format: uuid
            example:
              list_ids:
                - 6f1c1f0e-8f4e-4c3e-9a61-0d7c1b6f2a10
      responses:
        "200":
          description: The designated lists after the write, by name.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/InboundVipLists"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: |
            `role_forbidden`: a dashboard login without the `account_admin`
            role; `forbidden`: a widget token or a suspended tenant.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "422":
          $ref: "#/components/responses/Unprocessable"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
    get:
      operationId: listInboundVipLists
      tags:
        - inbound-queues
      summary: The debt lists designated as VIP for the inbound queue
      description: |
        The designated lists, by name then id, each with its name and when it
        was designated. `vip_lists: []` when none is designated. Admin only,
        like the write.
      responses:
        "200":
          description: The designated lists.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/InboundVipLists"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: |
            `role_forbidden`: a dashboard login without the `account_admin`
            role; `forbidden`: a widget token or a suspended tenant.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/inbound-queues/{id}/skills:
    put:
      operationId: setInboundQueueSkills
      tags:
        - inbound-queues
      summary: Replace the skills a queue demands
      description: |
        Declares which skills an agent must have to be offered this queue's
        calls (ADR #216 D4-D5): ALL of them (an agent needs every skill). An
        empty set means the queue demands nothing. The body REPLACES the whole
        set; a repeated skill counts once. `422 unprocessable` when any skill
        is malformed (`^[a-z0-9][a-z0-9_-]{0,39}$`), `skills` is not an array
        of strings, or there are more than 50; nothing is written then. A
        queue that is not an active queue of this tenant is `404`. The
        requirement is hard: a call nobody qualifies for keeps waiting, and the
        way out is the queue's overflow. So a queue that demands skills and has
        no overflow is SAVED and the answer carries the warning
        `required_skills_without_overflow` (ADR #216 D6); it never refuses the
        write. Writing works with `inbound_queue_skill_routing_enabled` OFF:
        what is stored stays inert. Retiring the queue deletes its skills.
        Same roles as the agent skills: admin only.
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required:
                - skills
              properties:
                skills:
                  type: array
                  maxItems: 50
                  items:
                    type: string
                    pattern: ^[a-z0-9][a-z0-9_-]{0,39}$
            example:
              skills:
                - idioma_es
      responses:
        "200":
          description: The queue's required skills after the write, sorted, and its
            warnings.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/InboundQueueSkills"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: |
            `role_forbidden`: a dashboard login without the `account_admin`
            role; `forbidden`: a widget token or a suspended tenant.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/Unprocessable"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
    get:
      operationId: getInboundQueueSkills
      tags:
        - inbound-queues
      summary: The skills a queue demands
      description: |
        The queue's required skills, sorted, and its warnings. `[]` when it
        demands nothing (and for a retired queue, whose skills were deleted).
        A queue that is not this tenant's is `404`. Admin only, like the write.
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: The queue's required skills and warnings.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/InboundQueueSkills"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: |
            `role_forbidden`: a dashboard login without the `account_admin`
            role; `forbidden`: a widget token or a suspended tenant.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/pricing:
    get:
      operationId: getPricing
      tags:
        - billing
      summary: The price list in force, per unit (public, no authentication)
      description: >
        The list prices the platform charges per unit, as data (core#2834):
        outbound and

        inbound voice per destination prefix, SMS segment per region, e-mail
        message per

        region, number rent per type, voice-AI minute, recording minute and
        storage

        GB-month. It feeds the pricing page and the guides, so it needs no
        tenant

        credential and returns nothing tenant-specific.


        Every unit of the table appears in `units`; a unit with no price loaded
        yet has

        `rates: []` — the list never invents a price. A price is a row with an

        `effective_from`; this read resolves them as of `as_of` (UTC today).
        `price` is a

        plain decimal string in USD, per the unit of the entry (per minute for
        voice, AI

        and recording minutes, per segment for SMS, per message for e-mail, per
        month for

        a number, per GB-month for storage). Voice rows carry
        `billing_increment_secs`.


        Cacheable for a minute (`Cache-Control: public, max-age=60`). Not served
        on the

        public widget listener.
      security: []
      responses:
        "200":
          description: The list in force.
          headers:
            Cache-Control:
              schema:
                type: string
                example: public, max-age=60
          content:
            application/json:
              schema:
                type: object
                required:
                  - currency
                  - as_of
                  - units
                properties:
                  currency:
                    type: string
                    example: USD
                  as_of:
                    type: string
                    format: date
                  units:
                    type: array
                    items:
                      type: object
                      required:
                        - unit
                        - rates
                      properties:
                        unit:
                          type: string
                          enum:
                            - voice_out
                            - voice_in
                            - sms_segment
                            - email_message
                            - did_rent
                            - ai_minute
                            - recording_minute
                            - storage_gb_month
                            - transcription_minute
                            - amd_check
                            - leg_minute
                        rates:
                          type: array
                          items:
                            $ref: "#/components/schemas/UnitRateEntry"
  /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"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
    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`. `voice.voice_id`, when given, must be one of `GET
        /v1/ai/voices`

        (otherwise `422`). Management scope: a `ddw_` browser token is `403`,
        and so is a `member` login

        (`403 role_forbidden`, core#2308): the route takes a `dd_` key or an
        `account_admin` login.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/AiAgentWrite"
            example:
              name: Ava — Collections Assistant
              voice:
                language: en
                voice_id: b24f41fd-00a3-4cd8-992a-a0c9f13f3ef1
                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: none
                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/AiAgentWriteForbidden"
        "409":
          $ref: "#/components/responses/Conflict"
        "422":
          $ref: "#/components/responses/Unprocessable"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/ai/voices:
    get:
      operationId: listAiVoices
      tags:
        - voice-ai
      summary: The governed AI voice catalog
      description: >
        The voices an AI agent may speak with (core#1539): the ONLY accepted
        values

        of `voice.voice_id` on `POST /v1/ai/agents` and `PATCH
        /v1/ai/agents/{id}`.

        Versioned data in the control plane, the same for every tenant; adding a

        voice is a release. `language` is the primary BCP-47 subtag the voice
        speaks

        (`en`); `?language=` filters by it and also takes a full tag (`en-US`
        selects

        `en`). A language with no voice is an empty list, not an error. Same
        audience

        as `GET /v1/ai/agents`.
      parameters:
        - name: language
          in: query
          required: false
          schema:
            type: string
          description: Primary language subtag (or a full BCP-47 tag) to filter by.
      responses:
        "200":
          description: The voices, in catalog order.
          content:
            application/json:
              schema:
                type: object
                required:
                  - voices
                properties:
                  voices:
                    type: array
                    items:
                      $ref: "#/components/schemas/AiVoiceCatalogEntry"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/ai/agents/drift:
    get:
      operationId: listAiAgentDrift
      tags:
        - voice-ai
      summary: Voice-AI drift alerts, with their evidence and what is being watched
      description: >
        The alerts of the drift evaluator, which runs for EVERY tenant every 15
        minutes (always

        on: there is no activation flag, and drift changes no agent, experiment
        or gate). It

        reads the hourly buckets (UTC, by the hour the call closed) of
        `ai_conversations` +

        `ai_call_metrics` per scope, `(agent, variant | none)` and `(model)`,
        for five

        metrics: `first_audio_p95_ms`, `handoff_rate`, `provider_error_rate`,
        `failed_rate` and

        `cost_per_call`. Two rules judge them: `zscore.v1` (the moving band of
        the insights

        evaluator: base of the previous 7 days, at least 24 buckets and 30
        calls, `k = 3.0`,

        only the harmful direction, which is upwards for all five) and
        `budget.v1` (the p95 of

        the first audio of the bucket above the latency budget, 900 ms).


        Every alert is its `opened` row with the evidence it was written with; a
        `cleared`

        row that cites it makes `state` `cleared` and fills `resolution`. Newest
        first; page

        with `cursor`.


        `coverage` says what is being judged: a series whose last bucket had
        fewer than 30

        calls is `insufficient_sample` and one without 24 baseline buckets is

        `insufficient_baseline`. An empty `drift` list is NOT "all fine" for
        those series.


        `agent_id`, `variant_id` and `model` are different scopes: `model`
        cannot be combined

        with the other two (`400`). Needs the tenant scope (a `dd_` key or a
        dashboard login).
      parameters:
        - name: agent_id
          in: query
          required: false
          description: Only this AI agent's scope.
          schema:
            type: string
            format: uuid
        - name: variant_id
          in: query
          required: false
          description: Only this variant of the agent, or `base` for the agent without a
            variant.
          schema:
            type: string
        - name: model
          in: query
          required: false
          description: Only the scope of every call that used this model.
          schema:
            type: string
            minLength: 1
            maxLength: 200
        - name: metric
          in: query
          required: false
          schema:
            $ref: "#/components/schemas/AiDriftMetric"
        - name: state
          in: query
          required: false
          schema:
            type: string
            enum:
              - open
              - cleared
        - name: limit
          in: query
          required: false
          description: Page size, 50 by default and at most 200.
          schema:
            type: integer
            minimum: 1
            maximum: 200
            default: 50
        - name: cursor
          in: query
          required: false
          description: The `next_cursor` of the previous page.
          schema:
            type: string
      responses:
        "200":
          description: A page of alerts and the coverage of the watched series.
          content:
            application/json:
              schema:
                type: object
                required:
                  - drift
                  - coverage
                  - next_cursor
                properties:
                  drift:
                    type: array
                    items:
                      $ref: "#/components/schemas/AiDriftEvent"
                  coverage:
                    type: array
                    items:
                      $ref: "#/components/schemas/AiDriftCoverage"
                  next_cursor:
                    type:
                      - string
                      - "null"
                    description: "`null` on the last page."
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
  /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"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
    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` `unprocessable`) and so is
        `voice.language`

        (`422` `disclosure_locked`: the disclosure audio is spoken with the
        agent's voice, so a

        new language would read the fixed text in the wrong language; re-sending
        the same

        language, or changing `voice_id` within it, is allowed, and a `voice`
        object without

        `language` counts as a change); a new `voice.voice_id` must be

        in `GET /v1/ai/voices` (`422`) — the id the agent ALREADY stores is
        accepted

        even when out of the catalog, so a pre-catalog agent stays editable;

        `policy_version` is monotonic;

        the `human_requested` handoff rule stays `locked`. `status` moves within

        `draft|live|paused|retired`; moving it to `retired` while an ACTIVE
        inbound

        route of type `ai_agent` still names the agent is `409` naming the
        route(s)

        (core#1547) — disable or re-point them first; pausing is never blocked.

        Going `live` needs explicit guards (core#2459):
        `policy.max_settlement_pct`,

        `min_payment_cents`, `max_plan_months`, at least one `prohibited`
        phrase, and a

        `payment_over` handoff rule when `take_payments` is `card_on_file`.
        Server switch

        `ai_agent_live_requires_guards`: `off`, `observe` (default: `200` plus
        `warnings`)

        or `enforce` (`422` `policy_incomplete` naming the missing keys: refused
        are the

        transition into `live` and an edit of a live agent that ADDS a missing
        key; an agent

        already live without guards is never cut and answers `warnings`).

        Management scope: a `ddw_` browser token is `403`, and so is a `member`
        login

        (`403 role_forbidden`, core#2308): the route takes a `dd_` key or an
        `account_admin` login.
      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"
                  warnings:
                    type: array
                    description: Present in `observe` and `enforce` mode when the agent is live with
                      guards missing.
                    items:
                      type: object
                      properties:
                        code:
                          type: string
                          enum:
                            - policy_incomplete
                        message:
                          type: string
                        missing:
                          type: array
                          items:
                            type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/AiAgentWriteForbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "422":
          $ref: "#/components/responses/Unprocessable"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
    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. `409` while an ACTIVE inbound route of type
        `ai_agent` still

        names the agent (core#1547): the body names the route(s); disable or
        re-point

        them first. Management scope: a `ddw_` browser token is `403`, and so is
        a `member` login

        (`403 role_forbidden`, core#2308): the route takes a `dd_` key or an
        `account_admin` login.
      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/AiAgentWriteForbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /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`.


        Every turn spends the house LLM, so it goes through the inference spend
        brake

        (core#2421): `message` above 4 000 bytes or `session_id` above 128 bytes
        is `400`;

        a tenant over its per-window quota of sandbox turns (or over the
        per-session

        quota) is `429` with `Retry-After`; a synthetic demo tenant is `403`

        (`synthetic_tenant`); and when the brake refuses the spend (no monthly
        ceiling, or

        the ceiling reached) the answer is `503` `sandbox_budget_unavailable`
        and the

        runtime is not called.
      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"
        "429":
          $ref: "#/components/responses/AiSandboxThrottled"
        "502":
          $ref: "#/components/responses/BadGateway"
        "503":
          $ref: "#/components/responses/SandboxUnavailable"
  /v1/ai/agents/{id}/voice-preview:
    post:
      operationId: previewAiAgentVoice
      tags:
        - voice-ai
      summary: Hear the agent's voice (fixed sample sentence)
      description: >
        Renders ONE fixed sentence in the agent's catalogued voice and returns a

        short-lived signed URL of the audio, so whoever builds the agent hears
        the

        voice before a consumer does. The sentence is NOT an input: it is fixed
        per

        language (the voice's primary language, `en` or `es`), so the render
        bill is

        bounded by `voices x languages` whatever a caller sends. The audio is

        content-addressed (`sha256(text|voice)`): the same voice reading the
        same

        sentence renders ONCE, for every agent and every tenant; every later
        request is

        served from the store (`cached: true`) with no provider call. `POST`
        because a

        miss spends paid provider time, even though the audio is a pure function
        of the

        voice — a prefetch or a crawler never triggers it.


        Only a request that has to RENDER spends the per-tenant budget (`429`,

        `Retry-After`); a cached request never does. `url` is signed at response
        time

        and must be played soon (it expires; it is never stored) — request a
        fresh one

        rather than keeping it.


        Refusals: `404` for an agent that does not exist or belongs to another
        tenant;

        `422` when the agent has no `voice.voice_id` or one outside `GET
        /v1/ai/voices`

        (a live call would use the cell's own voice, so a sample of that id
        would be a

        lie) or the voice speaks a language with no sample sentence; `503` with
        the

        failing stage in `error.reason` when the TTS service cannot produce the
        audio —

        never an empty audio. Management scope: a `ddw_` browser token is `403`.
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: The sample and where to play it.
          content:
            application/json:
              schema:
                type: object
                required:
                  - voice_preview
                properties:
                  voice_preview:
                    $ref: "#/components/schemas/AiVoicePreview"
              example:
                voice_preview:
                  url: https://media.example.com/tts/3f9a...c1?X-Signature=...
                  voice_id: b24f41fd-00a3-4cd8-992a-a0c9f13f3ef1
                  language: en
                  text: Hello, this is a preview of the voice your agent will use.
                  cached: true
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/Unprocessable"
        "429":
          $ref: "#/components/responses/VoicePreviewThrottled"
        "503":
          $ref: "#/components/responses/VoicePreviewUnavailable"
  /v1/ai/agents/{id}/experiments:
    get:
      operationId: listAiAgentExperiments
      tags:
        - voice-ai
      summary: A/B experiments of an AI agent
      description: Every experiment of the agent (any state), newest first, each with
        its variants in `position` order.
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: The agent's experiments.
          content:
            application/json:
              schema:
                type: object
                required:
                  - experiments
                properties:
                  experiments:
                    type: array
                    items:
                      $ref: "#/components/schemas/AiAgentExperiment"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
    post:
      operationId: createAiAgentExperiment
      tags:
        - voice-ai
      summary: Create an A/B experiment of an AI agent (draft)
      description: >
        Takes a `dd_` key or a dashboard login with the `account_admin` role,
        like editing the agent itself (`403 role_forbidden` for a `member`
        login); reads stay open to every tenant-scope token.


        Creates a `draft` experiment of 2 to 5 variants with exactly one
        control, over a

        `live` agent (any other agent is `409`). A variant may override only the
        CLOSED

        list `voice` (a `voice_id` of the voice catalog and/or a `pace` in [0,
        1]),

        `prompt_style` (one line of up to 600 characters, appended AFTER
        everything the

        agent says by obligation), `model` (a model of the model catalog) and
        `llm`

        (`reasoning_effort`, `prompt_form`). Any other key (`disclosure`,
        `policy`,

        `handoff_rules`, `tools`, `max_settlement_pct`...) is refused: the legal
        notice,

        the negotiation limits and the handoff are not something to test. Every
        refused

        field comes back in ONE `422`, each with its path in `error.details`.
        While the

        experiment runs, `agent-config` carries the variant of each call and the

        conversation records it; an agent with no experiment behaves as before.
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/AiAgentExperimentWrite"
      responses:
        "201":
          description: The draft experiment.
          content:
            application/json:
              schema:
                type: object
                required:
                  - experiment
                properties:
                  experiment:
                    $ref: "#/components/schemas/AiAgentExperiment"
        "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/AiAgentExperimentUnprocessable"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/ai/agents/{id}/experiments/{experiment_id}:
    get:
      operationId: getAiAgentExperiment
      tags:
        - voice-ai
      summary: Fetch an A/B experiment of an AI agent
      parameters:
        - $ref: "#/components/parameters/PathId"
        - $ref: "#/components/parameters/AiAgentExperimentId"
      responses:
        "200":
          description: The experiment with its variants.
          content:
            application/json:
              schema:
                type: object
                required:
                  - experiment
                properties:
                  experiment:
                    $ref: "#/components/schemas/AiAgentExperiment"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
    patch:
      operationId: updateAiAgentExperiment
      tags:
        - voice-ai
      summary: Edit a draft A/B experiment of an AI agent
      description: >
        Takes a `dd_` key or a dashboard login with the `account_admin` role,
        like editing the agent itself (`403 role_forbidden` for a `member`
        login); reads stay open to every tenant-scope token.


        `name` and/or `variants` (which REPLACES the whole set);
        `primary_metric` may only

        be repeated, never changed. Only a `draft` can be edited: once it runs
        (or ended)

        its variants are frozen and this is `409` (to test another prompt, voice
        or model,

        create another experiment).
      parameters:
        - $ref: "#/components/parameters/PathId"
        - $ref: "#/components/parameters/AiAgentExperimentId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/AiAgentExperimentWrite"
      responses:
        "200":
          description: The edited experiment.
          content:
            application/json:
              schema:
                type: object
                required:
                  - experiment
                properties:
                  experiment:
                    $ref: "#/components/schemas/AiAgentExperiment"
        "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/AiAgentExperimentUnprocessable"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
    delete:
      operationId: deleteAiAgentExperiment
      tags:
        - voice-ai
      summary: Delete a draft A/B experiment of an AI agent
      description: >
        Takes a `dd_` key or a dashboard login with the `account_admin` role,
        like editing the agent itself (`403 role_forbidden` for a `member`
        login); reads stay open to every tenant-scope token.


        Only a `draft` can be deleted; a running or ended experiment is `409`.
      parameters:
        - $ref: "#/components/parameters/PathId"
        - $ref: "#/components/parameters/AiAgentExperimentId"
      responses:
        "204":
          description: Deleted.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/ai/agents/{id}/experiments/{experiment_id}/results:
    get:
      operationId: getAiAgentExperimentResults
      tags:
        - voice-ai
      summary: Results and honest verdict of an A/B experiment of an AI agent
      description: >
        Recomputed from the ledger on every read (no counters): the
        conversations stamped

        with the experiment (`ai_conversations`), their per-call metrics
        (`ai_call_metrics`)

        and, for the unit of randomization, the debt of the call attempt. A
        **valid**

        conversation is a closed one whose outcome is neither `failed` nor
        `voicemail`; the

        rates of the primary metrics are over valid conversations, `failed_rate`
        over all

        closed ones. The same experiment read twice gives the same answer.


        `verdict.status` is `insufficient_data` (some arm is under 100 valid
        conversations or

        10 successes for a rate, under 100 calls with the measure for a mean),

        `no_significant_difference` or `winner`. Rates use the two-proportion z
        test, means

        (latency, cost) Welch's test in the normal approximation; two-sided,
        alpha 0.05,

        Bonferroni over the challengers. Which way is better depends on the
        metric: `ptp_rate`

        and `contained_rate` more, `handoff_rate`, `first_audio_p95_ms` and
        `cost_per_call`

        less. When the observed split of units deviates from the weights
        (chi-square, alpha

        0.001) `allocation_warning` is `true` and no `winner` is emitted.


        A cost with no rate loaded is `unpriced` (never 0): `cost.status` says
        `priced`,

        `partial` or `unpriced` per variant, and when the primary metric is
        `cost_per_call`

        and some arm has an unpriced call the verdict is `insufficient_data`
        with

        `reason: unpriced`. The amount of the cost is never published (it is the
        platform's

        provider cost; the tenant is billed per minute): each variant carries
        its

        `consumption` in minutes, and a `cost_per_call` verdict keeps its
        significance and

        its winner but not the magnitudes (`value`, `difference`, `ci_low`,
        `ci_high`).


        `safety.breaches` is the evidence of the compliance stop: a variant
        whose

        `compliance_violation_rate` is significantly higher than the control's
        (z, alpha 0.05,

        at least 100 closed conversations in both arms). Core ends the
        experiment on its own

        when that happens (`ended_cause` says which, `ended_reason` the
        evidence) and the agent goes back to its base

        configuration. Reading never changes the experiment.
      parameters:
        - $ref: "#/components/parameters/PathId"
        - $ref: "#/components/parameters/AiAgentExperimentId"
      responses:
        "200":
          description: The results of the experiment.
          content:
            application/json:
              schema:
                type: object
                required:
                  - results
                properties:
                  results:
                    $ref: "#/components/schemas/AiAgentExperimentResults"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/ai/agents/{id}/experiments/{experiment_id}/start:
    post:
      operationId: startAiAgentExperiment
      tags:
        - voice-ai
      summary: Start an A/B experiment of an AI agent
      description: >
        Takes a `dd_` key or a dashboard login with the `account_admin` role,
        like editing the agent itself (`403 role_forbidden` for a `member`
        login); reads stay open to every tenant-scope token.


        `draft` → `running`. The agent must be `live` and the variants are
        validated

        again against the voice and model catalogs as they are now (`422` with
        `details`

        if a model left the catalog since the draft was written). An agent with
        another

        experiment running, an agent that is not `live`, or an experiment that
        is not a

        `draft`, is `409`.
      parameters:
        - $ref: "#/components/parameters/PathId"
        - $ref: "#/components/parameters/AiAgentExperimentId"
      responses:
        "200":
          description: The experiment, now `running`.
          content:
            application/json:
              schema:
                type: object
                required:
                  - experiment
                properties:
                  experiment:
                    $ref: "#/components/schemas/AiAgentExperiment"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "422":
          $ref: "#/components/responses/AiAgentExperimentUnprocessable"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/ai/agents/{id}/experiments/{experiment_id}/stop:
    post:
      operationId: stopAiAgentExperiment
      tags:
        - voice-ai
      summary: End an A/B experiment of an AI agent
      description: >
        Takes a `dd_` key or a dashboard login with the `account_admin` role,
        like editing the agent itself (`403 role_forbidden` for a `member`
        login); reads stay open to every tenant-scope token.


        `running` → `ended` (terminal); the agent goes back to being answered as
        the base agent. Anything else → `409`.
      parameters:
        - $ref: "#/components/parameters/PathId"
        - $ref: "#/components/parameters/AiAgentExperimentId"
      responses:
        "200":
          description: The experiment, now `ended`.
          content:
            application/json:
              schema:
                type: object
                required:
                  - experiment
                properties:
                  experiment:
                    $ref: "#/components/schemas/AiAgentExperiment"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/ai/agents/{id}/experiments/{experiment_id}/promote:
    post:
      operationId: promoteAiAgentExperiment
      tags:
        - voice-ai
      summary: Adopt the winner of an A/B experiment as the agent's configuration
      description: >
        Takes a `dd_` key or a dashboard login with the `account_admin` role,
        like editing the agent itself (`403 role_forbidden` for a `member`
        login).


        Only an `ended` experiment whose verdict is `winner` (a challenger, not
        the control) can be promoted, and only once. The winner's overrides
        (`voice`, `model`, `prompt_style`, `llm`) are written into the agent
        (`voice` and `llm` merge over what it stores, the other two replace):
        `policy_version` goes up by one and the new snapshot carries the note
        `promovido desde el experimento <experiment_id>`. Every refusal is a
        `409` with its own stable `error.code`: `experiment_not_ended` (draft or
        running), `no_challenger_winner` (no verdict `winner`, or the control
        won), `winner_voice_unchanged` (the winner's configuration equals the
        agent's), `winner_breached_compliance` (the winner is one of the
        `safety.breaches`; the compliance stop flagged it) and
        `already_promoted`. After a `200`, the experiment (and the `GET`s and
        results) carry `promoted_at` and `promoted_policy_version`.
      parameters:
        - $ref: "#/components/parameters/PathId"
        - $ref: "#/components/parameters/AiAgentExperimentId"
      responses:
        "200":
          description: The agent after the promotion and the experiment.
          content:
            application/json:
              schema:
                type: object
                required:
                  - agent
                  - experiment
                properties:
                  agent:
                    $ref: "#/components/schemas/AiAgent"
                  experiment:
                    $ref: "#/components/schemas/AiAgentExperiment"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "422":
          $ref: "#/components/responses/AiAgentExperimentUnprocessable"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /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"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/ai/conversations/{id}:
    get:
      operationId: getAiConversation
      tags:
        - voice-ai
      summary: Fetch a conversation with transcript and outcome
      description: >
        **PII (core#2460, ADR #240).** Each transcript `text` comes out with the

        closed PII catalog redacted (a payment card number, an SSN, a labelled
        bank

        account number or a PIN/password), keeping at most the last four digits.

        `include_pii=true` asks for the original: it takes a `dd_` key or a

        dashboard login with the `account_admin` role (`403 role_forbidden`

        otherwise, never a silently redacted answer) and is audited.
      parameters:
        - $ref: "#/components/parameters/PathId"
        - name: include_pii
          in: query
          description: >
            `true` returns the transcript WITHOUT PII redaction. Requires a
            `dd_` key

            or an `account_admin` login: `403 role_forbidden` for anyone else.
            Absent

            or `false` ⇒ redacted. Anything that is not a boolean ⇒ `400`.
          schema:
            type: boolean
            default: false
      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"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /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 gates as the
        supervision

        operations: `supervisor_ext` must carry role `supervisor|admin` (ADR
        #116),

        and the principal must be allowed to act as that agent (ADR #135: a
        seated

        `ddu_` only as its own seat — `403` `forbidden`; a seatless `ddu_`
        naming a

        third party only as `account_admin` once `ddu_author_role_enforced` is
        on —

        `403` `role_forbidden`); every refusal is 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"
        "422":
          $ref: "#/components/responses/Unprocessable"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
        "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`.


        Role rules (core#2438): a `dd_` key or an `account_admin` login; a
        `member` login is `403 role_forbidden` (per-tenant knob
        `compliance_config_admin_only`, default on).
      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"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /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_`).


        Role rules (core#2438): a `dd_` key or an `account_admin` login; a
        `member` login is `403 role_forbidden` (per-tenant knob
        `compliance_config_admin_only`, default on).
      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"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /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"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /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"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /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"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /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"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/privacy/subject-exports:
    post:
      operationId: createSubjectExport
      tags:
        - recordings
      summary: Export the data held about one person
      description: >
        Gathers, for ONE person given by `phone` (E.164) or `consumer_ref`
        (exactly one),

        what the platform keeps of them in this tenant, and writes it as a ZIP
        to the exports

        bucket (it expires on its own schedule): one JSON per category (`debts`,
        `contacts`,

        `call_attempts`, `consents`, `sms_messages`, `email_messages`,
        `ai_conversations`,

        `recordings`, `transcripts`) and a `manifest.json` that names the tables
        read, the

        rows per category, the categories cut by the row cap and what is omitted
        and why (the

        recording audio is never packaged). The response carries the same
        manifest and a

        short-lived signed URL.


        A person with NO data gets the same ZIP with every count at zero, never
        a `404`: the

        route does not reveal whether the person exists.


        **PII.** Redacted by default: phone numbers come out as `***` plus the
        last four

        digits, free text (SMS, email, AI conversation, transcript) passes
        through the PII

        redaction, and `account_number` and `debtor_name` are withheld.
        `include_pii: true`

        asks for the unredacted export and is audited like the other raw
        exports.


        **Who.** A `dd_` key or a dashboard login with the `account_admin` role.
        A `member`

        gets `403 role_forbidden` (not a masked answer), with or without
        `include_pii`.


        **Flag.** Behind `privacy_subject_export_enabled`, off by default: `503

        subject_export_disabled` and nothing is read.


        **Trail.** Each request writes an append-only `requested` event BEFORE
        reading and a

        `delivered` one when the archive is stored; the person is recorded only
        as an HMAC.

        If the request cannot be recorded, nothing is exported (`503
        audit_unavailable`).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SubjectExportRequest"
            example:
              phone: "+13055550123"
              include_pii: false
      responses:
        "202":
          description: The export was produced; the archive is at `url`.
          content:
            application/json:
              schema:
                type: object
                required:
                  - subject_export
                properties:
                  subject_export:
                    $ref: "#/components/schemas/SubjectExport"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
        "503":
          description: >
            `subject_export_disabled` (the flag is off for this tenant),
            `audit_unavailable`

            (the request could not be recorded, nothing was exported) or
            `storage_unavailable`

            (the export store did not take the archive).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: subject_export_disabled
                  message: subject exports are not enabled for this tenant
  /v1/privacy/erasure-requests:
    post:
      operationId: createErasureRequest
      tags:
        - recordings
      summary: Ask what erasing one person would remove and what it must keep
      description: >
        For ONE person given by `phone` (E.164) or `consumer_ref` (exactly one),
        finds what the

        tenant holds of them (phone by blind index) and classifies every
        category: what would be

        anonymized, and what is RETAINED with the reason and the date until
        which it is kept

        (the records floor of the tenant, a recording's `legal_hold` or
        retention date,

        suppression kept as a hash). Copies that derive from the rows (the
        analytic ClickHouse

        copy, audio under Object Lock) are declared, not hidden.


        **Dry run by default.** `dry_run` defaults to `true`: it reads,
        classifies and records the

        request and changes no row. `dry_run: false` EXECUTES, and needs the
        second flag below:

        it purges the audio (with its transcript) of the person's expired,
        un-held recordings,

        empties the AI transcript of the expired conversations (only when

        `ai_conversation_purge_enabled` is on), and anonymizes the personal data
        of every other

        row that is past the tenant's records floor (never fewer than 3 months):
        numbers, blind

        indexes, last-4, names, account numbers, message bodies, notes. The rows
        stay (the

        compliance audit chain detects deleted rows); only their personal
        content goes. The

        same pass covers the contacts' emails (address), the contactability
        scores of the person's

        numbers (the row goes, except one under durable quarantine) and the
        settled webhook

        deliveries of their events (payload emptied; a delivery still pending is
        kept). The

        analytic ClickHouse copy (`dialer.cdr`) has its last-4 blanked by
        `call_id` BEFORE the

        rows are anonymized; the `derived_copies` entry reports `mutated`,
        `nothing_to_mutate`

        or `not_configured`. The execution seals a tombstone in the tenant's
        compliance audit chain, in the same

        transaction (`chain_seq`), and the chain keeps verifying. Repeating the
        request is safe.


        **Suppression survives.** DNC listings, cease-and-desist records and
        revoked consents are

        never counted as erasable.


        **Who.** A `dd_` key or a dashboard login with the `account_admin` role
        (`403

        role_forbidden` otherwise).


        **Flags.** Behind `privacy_erasure_enabled` (the dry run), off by
        default and per tenant:

        `403 privacy_erasure_disabled` and nothing is written. `dry_run: false`
        additionally needs

        `privacy_erasure_execute_enabled`, also off by default: `403

        privacy_erasure_execution_disabled` and nothing is written.


        **What it never touches.** Suppression (DNC, cease-and-desist, revoked
        consents and the

        append-only suppression history), recordings under `legal_hold` or
        inside their

        retention, and any record younger than the floor. Copies outside
        Postgres are declared in

        `derived_copies`: the analytic copy keeps only the last 4 digits keyed
        by `call_id`, and

        its mutation is not part of this version.


        **Trail.** Each request writes an append-only `privacy_requests` row
        (who, when, the

        person only as an HMAC, the report). If it cannot be recorded, no report
        is returned

        (`503 audit_unavailable`).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ErasureRequestInput"
            example:
              phone: "+13055550123"
              dry_run: true
      responses:
        "202":
          description: The report (of the dry run, or of the execution with `anonymized`
            per category).
          content:
            application/json:
              schema:
                type: object
                required:
                  - erasure_request
                properties:
                  erasure_request:
                    $ref: "#/components/schemas/ErasureRequest"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
        "503":
          description: "`audit_unavailable`: the request could not be recorded, so no
            report was produced; or `erasure_failed`: the database rejected the
            anonymization, or the ClickHouse copy could not be mutated, and
            nothing was changed."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
  /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, but a selection with
        more

        rows than the platform row limit (`recording_export_max_rows`, default

        50 000) is rejected with `422 export_too_large` — never cut short: a
        `done`

        export always carries EVERY row that matches. The error carries
        `max_rows`

        and `matching_rows`; narrow the filters and retry.


        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. A cached job whose

        archive is no longer in the export store is not reused: the request

        regenerates it.


        The archive is written to a dedicated export store, never to the

        recording store (whose retention would seal it): it carries phone
        numbers

        and account references, and the export store expires it. The job row

        stays; once its archive is gone, `GET /v1/recording-exports/{job_id}`

        reports `status: expired`.


        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.


        **PII (core#2018, ADR #240).** The CSV inside the archive is redacted by

        default: free text such as `account_ref` that carries a payment card
        number,

        an SSN, a bank account number or a PIN/password comes out masked,
        keeping at

        most the last four digits. `include_pii: true` asks for the archive
        WITHOUT

        redaction; it takes a `dd_` key or a dashboard login with the

        `account_admin` role (`403 role_forbidden` otherwise), is audited, and

        produces its own archive — a raw request never reuses (or feeds) a
        redacted

        one in the dedup window. `GET /v1/recording-exports/{job_id}` of a raw
        job

        answers `403 role_forbidden` to anyone else.
      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"
        "403":
          $ref: "#/components/responses/Forbidden"
        "422":
          description: >
            `range_too_large`: the requested export range exceeds the platform

            limit (`recording_export_max_range_days`, default 366 days). Narrow
            the

            `from`/`to` window and retry.


            `export_too_large`: the filters match more recordings than the

            platform row limit (`recording_export_max_rows`, default 50 000).
            The

            error adds `max_rows` and `matching_rows`; no job is created. Narrow

            the filters and retry.
          content:
            application/json:
              schema:
                anyOf:
                  - $ref: "#/components/schemas/ErrorEnvelope"
                  - $ref: "#/components/schemas/RecordingExportTooLarge"
              examples:
                range_too_large:
                  value:
                    error:
                      code: range_too_large
                      message: requested range exceeds the 366-day export limit
                export_too_large:
                  value:
                    error:
                      code: export_too_large
                      message: the filters match 120000 recordings, above the 50000-row export limit;
                        narrow the filters and retry
                      max_rows: 50000
                      matching_rows: 120000
        "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 export store could not take the archive (unreachable,
            misconfigured or refusing the write). The export store is its own
            store, independent of the recording audio store; a `503` is returned
            whenever it fails, 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`.


        `expired`: the job completed but its archive is no longer in the export

        store (the store expires archives on its own schedule). `url` and

        `expires_at` are `null`; send a new `POST /v1/recording-exports` to

        regenerate. The job itself is kept as evidence of who exported what. A
        job

        created with `include_pii: true` (`pii: raw`) is `403 role_forbidden`
        for a

        caller without the `account_admin` role. If

        the store cannot be reached the job stays `done` with its signed URL —

        `expired` is only reported when the archive is known to be gone.
      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"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /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"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /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"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
      parameters:
        - $ref: "#/components/parameters/PathId"
  /v1/recordings/{id}/original-url:
    post:
      operationId: mintRecordingOriginalUrl
      tags:
        - recordings
      summary: Mint a short-lived URL for the UNREDACTED audio
      description: >
        The default view (`POST /v1/recordings/{id}/playback-url`) plays the
        PII-redacted copy.

        This is the only way to the original. Requires a `dd_` tenant key or a
        dashboard login

        with the `account_admin` role (anything else is `403 role_forbidden`)
        and a non-empty

        `reason` (at most 500 characters). The actor and the reason are written
        to an

        append-only access log BEFORE the URL is returned.
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - reason
              properties:
                reason:
                  type: string
                  maxLength: 500
            example:
              reason: "Dispute 2026-0417: customer denies the payment promise"
      responses:
        "200":
          description: A freshly signed URL for the original audio.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RecordingPlaybackUrl"
        "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/recordings/{id}/redaction:
    get:
      operationId: getRecordingRedaction
      tags:
        - recordings
      summary: The PII redaction of a recording's audio
      description: >
        Spans (time and category only, never the value), method and version.
        `404` when the

        recording has no redaction.
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: The redaction.
          content:
            application/json:
              schema:
                type: object
                properties:
                  redaction:
                    $ref: "#/components/schemas/RecordingRedaction"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
    post:
      operationId: redactRecordingAudio
      tags:
        - recordings
      summary: Redact PII spans of a recording's audio
      description: >
        Same role gate as `original-url`. The spans are unioned with those
        already stored and

        the redacted copy is rebuilt from the original (idempotent, `version`
        goes up when

        something new was added). WAV is cut by core; Opus (the default format)
        is redacted by

        the voice-ai audio redactor, which returns the copy in the same codec
        and duration. Any

        other format is `422 unsupported_codec` and the message names the
        format. When the

        redactor cannot answer, answers an error or a reply that does not fit
        its contract the

        call fails (`503 redactor_unavailable`, or `422 redaction_rejected` for
        a recording it

        refuses) and the recording is NOT marked as redacted: nothing was
        written. A segment that starts

        after the end of the audio is `422 segment_out_of_range`.
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - segments
              properties:
                method:
                  type: string
                  enum:
                    - silence
                    - beep
                  default: silence
                segments:
                  type: array
                  minItems: 1
                  maxItems: 500
                  items:
                    $ref: "#/components/schemas/RecordingRedactionSegment"
      responses:
        "200":
          description: The redaction after the call.
          content:
            application/json:
              schema:
                type: object
                properties:
                  redaction:
                    $ref: "#/components/schemas/RecordingRedaction"
        "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"
        "503":
          description: "`storage_unavailable`: the recording store cannot read the
            original or write the redacted copy right now;
            `redactor_unavailable`: the audio redactor (Opus) did not answer or
            answered something unusable. Nothing was persisted; retry later."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
  /v1/recordings/{id}/transcript:
    get:
      operationId: getRecordingTranscript
      tags:
        - recordings
      summary: The transcript of a recording
      description: >
        The speech-to-text transcript of ONE recording, with its decrypted timed
        segments

        ordered by `seq`. It is raw personal data of a call, so only a dashboard
        login

        (`ddu_`) with a supervisor or admin seat, or an `account_admin` login
        without a

        seat, may read it: a `dd_` key, a widget token and an agent seat are

        `403 role_forbidden`. Every delivery appends a read event (who, when,
        which

        recording, how many segments; never the text) in the same transaction as
        the read.


        The text is the PII-redacted default view (`redacted: true`): card
        numbers, social

        security numbers, bank accounts and PINs/passwords are replaced by masks
        or

        `[REDACTED]`. The original is only reachable through

        `POST /v1/recordings/{id}/transcript/original`.


        `404` when the recording has no transcript in this tenant: it belongs to
        another

        tenant, its audio was purged (the purge deletes the transcript with it),
        it was not

        eligible, or the id is malformed. A recording under `legal_hold` keeps
        its transcript.
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: The transcript.
          content:
            application/json:
              schema:
                type: object
                required:
                  - transcript
                properties:
                  transcript:
                    $ref: "#/components/schemas/RecordingTranscript"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "503":
          description: The read event could not be recorded, so no text was returned.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
  /v1/recordings/{id}/transcript/original:
    post:
      operationId: readRecordingTranscriptOriginal
      tags:
        - recordings
      summary: The UNREDACTED transcript of a recording
      description: >
        The default view (`GET /v1/recordings/{id}/transcript`) has the PII
        redacted. This is the

        only way to the original text, kept encrypted for audit (Reg F).
        Requires a `dd_` tenant

        key or a dashboard login with the `account_admin` role (anything else is

        `403 role_forbidden`) and a non-empty `reason` (at most 500 characters).
        The actor, the

        reason and the segment count (never the text) are written to an
        append-only access log

        BEFORE the text is returned: if the row cannot be written, no text
        leaves (`503`).

        `404` as in the default view.
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - reason
              properties:
                reason:
                  type: string
                  maxLength: 500
            example:
              reason: "Dispute 2026-0417: customer denies the payment promise"
      responses:
        "200":
          description: "The transcript, with `redacted: false`."
          content:
            application/json:
              schema:
                type: object
                required:
                  - transcript
                properties:
                  transcript:
                    $ref: "#/components/schemas/RecordingTranscript"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "503":
          description: The access could not be recorded, so no text was returned.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
  /v1/transcripts/search:
    get:
      operationId: searchTranscripts
      tags:
        - recordings
      summary: Search a word or phrase in the transcripts of a window
      description: >
        Finds the recordings of this tenant whose transcript contains `q`,
        newest first.

        The words of `q` are looked up in a BLIND index (a keyed hash with the
        tenant inside:

        the same word hashes differently in two tenants, and no word is stored
        in the clear);

        only the candidate segments of the candidate recordings are decrypted,
        and a phrase is

        verified on the decrypted text: the words must appear in a row inside
        ONE segment.

        Matching is exact on the normalised word (case, accents and punctuation
        are ignored;

        no stemming, no synonyms). A recording whose audio was purged has no
        transcript and

        never appears; one under `legal_hold` does.


        `from` and `to` are mandatory, `from` must be before `to` and the window
        may not

        exceed 31 days (`400` otherwise); `q` must contain at least one and at
        most 8 words.

        A call examines at most 100 candidate recordings and 500 segments: when
        that cap stops

        the search before the page is full, `capped` is `true` and `next_cursor`
        carries on from

        where it stopped. It is raw personal data of calls, so the gate is the
        one of the

        single transcript: a dashboard login (`ddu_`) with a supervisor or admin
        seat, or an

        `account_admin` login without a seat; a `dd_` key, a widget token and an
        agent seat are

        `403 role_forbidden`.


        Every search that runs appends an event (who, when, the HASH of the
        query, the window,

        the filters, how many recordings were examined and returned; never the
        text) in the

        same transaction: if it cannot be written there is no result (`503`).
      parameters:
        - name: q
          in: query
          required: true
          description: The word or phrase (1 to 8 words).
          schema:
            type: string
            minLength: 1
        - name: from
          in: query
          required: true
          description: RFC 3339 lower bound on the recording start (inclusive).
          schema:
            type: string
            format: date-time
        - name: to
          in: query
          required: true
          description: RFC 3339 upper bound on the recording start; at most 31 days after
            `from`.
          schema:
            type: string
            format: date-time
        - name: agent_id
          in: query
          required: false
          description: Only recordings of this agent.
          schema:
            type: string
            format: uuid
        - name: campaign_id
          in: query
          required: false
          description: Only recordings of this campaign.
          schema:
            type: string
            format: uuid
        - name: limit
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 50
            default: 20
          description: Page size (a value over the ceiling is capped, not refused).
        - name: cursor
          in: query
          required: false
          schema:
            type: string
          description: Opaque keyset cursor, the `next_cursor` of the previous page.
      responses:
        "200":
          description: The recordings that match, with the fragments around the matches.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TranscriptSearchPage"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/TranscriptSearchThrottled"
        "503":
          description: The search event could not be recorded, so no result was returned.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
  /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`).


        Role rules (core#2438): a `dd_` key or an `account_admin` login; a
        `member` login is `403 role_forbidden` (per-tenant knob
        `compliance_config_admin_only`, default on). The `GET` of this resource
        is not affected.
      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"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /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"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
    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 tenant's EFFECTIVE floor (39 until it declares its business types;
        see

        `GET /v1/business-types`) — a lower value is `422 unprocessable`. It
        also has a

        CEILING (core#2423): what the plan includes (`max(3, floor)` months)
        plus 12 months

        for each block of extra retention the tenant has bought — a higher value
        is `422

        unprocessable` too. A policy already stored above its ceiling keeps its
        value and

        may be saved again at that value or lower, never raised. 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`).


        Role rules (core#2438): a `dd_` key or an `account_admin` login; a
        `member` login is `403 role_forbidden` (per-tenant knob
        `compliance_config_admin_only`, default on). The `GET` of this resource
        is not affected.
      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"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/business-types:
    get:
      operationId: getBusinessTypes
      tags:
        - recordings
      summary: Read the tenant's declared business types and the recording-retention
        floor they imply
      description: >
        ADR #246 (core#2065). Answers three things at once so a client never has
        to

        join them: the types the tenant DECLARED (`declared`, `null` when it
        declared

        nothing), the CLOSED vocabulary it may declare with each type's audio
        floor and

        legal source (`supported`), and the EFFECTIVE floor with the type(s)
        that impose

        it (`retention_floor`).


        `retention_floor.months` is `legal_floor_months + safety_margin_months`:
        the

        MAXIMUM audio floor of the declared types plus the platform margin (3
        months,

        inherited from planning, not a legal number). It is NEVER 0: a type
        whose law

        asks for no audio still keeps the platform's included retention. A
        tenant that

        declared nothing gets the maximum of the supported types (`basis:

        undeclared_maximum`) — omitting the declaration can never lower the
        floor.

        A floor that cannot be resolved is `503`, never a guessed number.


        Any console principal reads (a `dd_` key or a dashboard login of any
        role).

        The sources are US federal rules only; state law is NOT covered (ADR
        #246,

        «Límite de la investigación»).
      responses:
        "200":
          description: The declaration and the effective floor.
          content:
            application/json:
              schema:
                type: object
                required:
                  - business_types
                properties:
                  business_types:
                    $ref: "#/components/schemas/BusinessTypes"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
        "503":
          description: The tenant's declaration could not be read, so no floor is published.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
    put:
      operationId: putBusinessTypes
      tags:
        - recordings
      summary: Declare the tenant's business types
      description: >
        Replaces the declaration. **Account administrators only**: the caller
        must be a

        dashboard login (`ddu_`) with the `account_admin` role. A `dd_` key
        reads and

        gets `403 role_forbidden`, and so does a `member` login — lowering a
        floor is a

        decision of a person who answers for it (ADR #246, decision 6).


        `business_types` is REQUIRED: a list drawn from the closed vocabulary
        (see

        `supported`), or `null` / `[]` to withdraw the declaration (back to the

        fail-closed maximum). The list is stored sorted and de-duplicated. A
        type

        outside the vocabulary is `422 unprocessable`; an absent key, a value
        that is not

        a list or `null`, or an unknown body key is `400 bad_request`.


        Every change — up or down — writes ONE append-only audit row in the same

        transaction: who, when, the previous value and the new one, and the
        floor each

        implied. Declaring what is already declared writes nothing (`changed:
        false`).

        A lower floor applies to NEW recordings only: `retained_until` stays
        frozen per

        recording and objects already sealed keep their seal.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/BusinessTypesWrite"
            example:
              business_types:
                - cobranza
      responses:
        "200":
          description: The declaration now in force and what the change did.
          content:
            application/json:
              schema:
                type: object
                required:
                  - business_types
                  - changed
                  - lowered
                  - previous_floor_months
                properties:
                  business_types:
                    $ref: "#/components/schemas/BusinessTypes"
                  changed:
                    type: boolean
                    description: "`false` when the body declared what was already declared (no audit
                      row)."
                  lowered:
                    type: boolean
                    description: "`true` when the effective floor went down; the audit row names who
                      did it."
                  previous_floor_months:
                    type: integer
                    description: The effective floor before this call.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "422":
          $ref: "#/components/responses/Unprocessable"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
        "503":
          description: The tenant could not be read, so nothing was declared.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
  /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"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
    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.


        **Opt-out instructions (core#1305, 47 CFR §64.1200(b)(3)).** Write the

        instruction in your own words, with the marker `{optout_digit}` where
        the

        key goes: `Press {optout_digit} to stop these calls.` Every TTS / IVR /

        survey campaign plays this disclosure first and replaces each marker
        with

        ITS OWN `optout_digit`, so the key the consumer hears is always the key

        that works. Never write the key itself. A text without the marker is

        still stored (it is a valid disclosure), but no TTS / IVR / survey

        campaign dials with it: the campaign's `/stats` reports

        `prompt_unavailable_reason: optout_instructions_missing`.


        `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`).


        Role rules (core#2438): a `dd_` key or an `account_admin` login; a
        `member` login is `403 role_forbidden` (per-tenant knob
        `compliance_config_admin_only`, default on). The `GET` of this resource
        is not affected.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ArtificialVoiceDisclosureWrite"
            example:
              text: Hello, this is an automated call from Acme Collections. To stop these
                calls, press {optout_digit}.
      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"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/dispositions:
    get:
      operationId: listDispositions
      tags:
        - dispositions
      summary: List the dispositions of the dialer.v1 contract
      description: |
        The `dialer.v1.Disposition` enum in REST spelling, one entry per value
        except `DISPOSITION_UNSPECIFIED`, ordered by enum `number`. `value` is
        the lowercase form every REST surface speaks (`answered_human`); `enum`
        and `number` are the contract's own name and number, so a consumer of
        the protobuf contract can map one to the other without a third
        spelling. `value` is always `enum` without its `DISPOSITION_` prefix,
        lowercased.

        This is the contract's CLOSED set. The `disposition` of a row of
        `GET /v1/call_attempts`, and the `code` of a disposition rule, are an
        open vocabulary written verbatim, so they can also carry codes that are
        not listed here (`abandoned`, `delivered`, `ai_conversation`,
        `invalid_number`, ...). The catalog does not narrow them.

        The list is the same for every tenant and carries no tenant data.
      responses:
        "200":
          description: The catalog.
          content:
            application/json:
              schema:
                type: object
                required:
                  - dispositions
                properties:
                  dispositions:
                    type: array
                    items:
                      $ref: "#/components/schemas/DispositionCatalogEntry"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/disposition-rules:
    get:
      operationId: listDispositionRules
      tags:
        - disposition-rules
      summary: List the tenant's retry policy by outcome
      description: |
        Every TENANT-scope rule — active or not, any effect — ordered by `code`,
        then `priority`. A switched-off rule is still listed (`active: false`):
        rules are never deleted, so the history of a cadence stays readable.
        Every rule listed here has `campaign_id: null`; a campaign's own rules
        are read at `GET /v1/campaigns/{id}/disposition-rules`, and for the
        codes a campaign has active rules for, those win over these in that
        campaign.

        A tenant with NO active rule has an inert policy: closing an attempt
        changes nothing about when its debt is dialable again. The canonical
        cadences are installed only on request (`POST
        /v1/disposition-rules/seed-defaults`).

        `max_attempts` bounds how many times THIS policy reschedules a debt
        (the count is every original attempt of the debt, any outcome). It is
        not a regulatory ceiling and does not move one: the compliance gates
        re-run at originate time on every dial, whatever the policy says.
      responses:
        "200":
          description: The tenant's rules.
          content:
            application/json:
              schema:
                type: object
                required:
                  - disposition_rules
                properties:
                  disposition_rules:
                    type: array
                    items:
                      $ref: "#/components/schemas/DispositionRule"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
    put:
      operationId: setDispositionRules
      tags:
        - disposition-rules
      summary: Replace the tenant's reschedule/suppress policy
      description: >
        PUT semantics over the two writable effects (`reschedule`, `suppress`),

        in the TENANT scope: every listed `(code, effect)` is written as given,

        and every ACTIVE tenant-scope `reschedule`/`suppress` rule that is NOT

        listed is switched off (`active: false`, never deleted). An empty

        `rules` array turns the tenant's policy off. Rules with any other

        effect, and every campaign's own rules, are neither written nor retired

        by this call.


        Validation is strict and reports EVERY problem in one `422`, each one

        naming the element and the field (`rules[1].params.max_attempts must be

        an integer between 1 and 100`): `code` non-empty (at most 64

        characters, no surrounding whitespace, no null byte); `delay_seconds`

        and `max_delay_seconds` JSON integers between 0 and 2592000, with

        `max_delay_seconds >= delay_seconds`; `factor` a number between 1 and

        10; `max_attempts` a JSON integer between 1 and 100; `priority` a JSON

        integer between 0 and 2147483647; `reason` without a null byte; at most

        one rule per `(code, effect)`; at most 100 rules; no unknown field at

        any level. The two ceilings on `factor` and `max_attempts` together

        keep the growth `factor^(attempts - 1)` finite. `factor` (default 2.0)
        and

        `max_delay_seconds` (default 86400) may be omitted: the value the

        engine applies is then STORED, so a read shows what is applied. An

        omitted `priority` is 100 and an omitted `active` is `true`.


        The delay grows as `delay_seconds * factor^(attempts - 1)`, capped at

        `max_delay_seconds`; once `max_attempts` is reached the debt is closed

        instead of rescheduled. The next dialable instant is computed from the

        closed attempt's end time, not the wall clock, so re-processing the same

        attempt yields the same instant.


        Every change is recorded in the tenant's append-only config audit trail

        (who, from what, to what) in the same transaction as the write; an

        unchanged rule writes nothing. Tenant-management scope (`dd_` key or

        dashboard login; `ddw_` → `403`).


        Role rules (core#2438): a `dd_` key or an `account_admin` login; a
        `member` login is `403 role_forbidden` (per-tenant knob
        `compliance_config_admin_only`, default on). The `GET` of this resource
        is not affected.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/DispositionRulesWrite"
            example:
              rules:
                - code: busy
                  effect: reschedule
                  priority: 10
                  params:
                    delay_seconds: 1200
                    factor: 2
                    max_delay_seconds: 86400
                    max_attempts: 6
                - code: invalid_number
                  effect: suppress
                  params:
                    reason: invalid_number
      responses:
        "200":
          description: Every rule of the tenant after the write (same shape as the GET).
          content:
            application/json:
              schema:
                type: object
                required:
                  - disposition_rules
                properties:
                  disposition_rules:
                    type: array
                    items:
                      $ref: "#/components/schemas/DispositionRule"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "422":
          $ref: "#/components/responses/Unprocessable"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/disposition-rules/seed-defaults:
    post:
      operationId: seedDispositionRules
      tags:
        - disposition-rules
      summary: Install the canonical retry cadences
      description: >
        Writes the canonical rules: `busy` → reschedule (1200 s, factor 2.0,

        6 attempts), `no_answer` → reschedule (7200 s, factor 1.5, 5 attempts),

        `answered_machine` → reschedule (86400 s, factor 1.0, 3 attempts), and

        `invalid_number` → suppress; every reschedule caps at 86400 s. A

        canonical code the tenant had customised is restored to these values;

        every other rule is left alone. Idempotent: a second call changes

        nothing and records nothing. Explicit on purpose — installing a policy

        changes what the tenant dials, so no deploy or migration ever does it.

        Audited like the PUT. Takes no body (any field is a `400`).

        Tenant-management scope (`dd_` key or dashboard login; `ddw_` → `403`).


        Role rules (core#2438): a `dd_` key or an `account_admin` login; a
        `member` login is `403 role_forbidden` (per-tenant knob
        `compliance_config_admin_only`, default on). The `GET` of this resource
        is not affected.
      responses:
        "200":
          description: Every rule of the tenant after seeding (same shape as the GET).
          content:
            application/json:
              schema:
                type: object
                required:
                  - disposition_rules
                properties:
                  disposition_rules:
                    type: array
                    items:
                      $ref: "#/components/schemas/DispositionRule"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/campaigns/{id}/disposition-rules:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    get:
      operationId: getCampaignDispositionRules
      tags:
        - disposition-rules
      summary: Read one campaign's retry policy
      description: |
        `disposition_rules` are the campaign's OWN rules, active or not (same
        order and shape as `GET /v1/disposition-rules`, with `campaign_id` set).

        `effective_rules` are the ACTIVE rules that apply to an attempt of this
        campaign. The precedence is per `code`: when the campaign has at least
        one active rule for a code, the campaign's rules for that code are the
        policy for it in this campaign and the tenant's rules for that code are
        not applied here; for every other code the tenant's rules apply. Each
        entry's `campaign_id` says which scope it came from (`null` = the
        tenant's). This list is computed by the same precedence the dialer
        applies when an attempt closes, so it is what is applied. The campaign
        that decides is the campaign of the ATTEMPT.

        A campaign with no rules of its own dials exactly under the tenant's
        policy. A campaign that is not the tenant's (or a malformed id) is a
        `404`.
      responses:
        "200":
          description: The campaign's own rules and the rules that apply to it.
          content:
            application/json:
              schema:
                type: object
                required:
                  - campaign_id
                  - disposition_rules
                  - effective_rules
                properties:
                  campaign_id:
                    type: string
                    format: uuid
                  disposition_rules:
                    type: array
                    items:
                      $ref: "#/components/schemas/DispositionRule"
                  effective_rules:
                    type: array
                    items:
                      $ref: "#/components/schemas/DispositionRule"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
    put:
      operationId: setCampaignDispositionRules
      tags:
        - disposition-rules
      summary: Replace one campaign's reschedule/suppress policy
      description: >
        Same body, validation and PUT semantics as `PUT /v1/disposition-rules`,

        over this campaign's own rules only: every listed `(code, effect)` is

        written for the campaign, and every ACTIVE campaign

        `reschedule`/`suppress` rule that is NOT listed is switched off

        (`active: false`, never deleted). The tenant's rules and every other

        campaign's are never written nor retired by this call. An empty `rules`

        array switches the campaign's own rules off, which returns the campaign

        to the tenant's policy.


        A campaign rule can only postpone (`reschedule`) or close (`suppress`) a

        debt, like a tenant rule; the compliance gates re-run at originate time

        on every dial. Audited in the tenant's config trail in the same

        transaction, under the key

        `campaign_disposition_rule:<campaign_id>:<code>:<effect>`.

        Tenant-management scope (`dd_` key or dashboard login; `ddw_` → `403`).

        A campaign that is not the tenant's is a `404` and nothing is written.


        Role rules (core#2438): a `dd_` key or an `account_admin` login; a
        `member` login is `403 role_forbidden` (per-tenant knob
        `compliance_config_admin_only`, default on). The `GET` of this resource
        is not affected.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/DispositionRulesWrite"
            example:
              rules:
                - code: no_answer
                  effect: reschedule
                  priority: 10
                  params:
                    delay_seconds: 14400
                    factor: 1
                    max_delay_seconds: 14400
                    max_attempts: 3
      responses:
        "200":
          description: The campaign's own rules after the write.
          content:
            application/json:
              schema:
                type: object
                required:
                  - campaign_id
                  - disposition_rules
                properties:
                  campaign_id:
                    type: string
                    format: uuid
                  disposition_rules:
                    type: array
                    items:
                      $ref: "#/components/schemas/DispositionRule"
        "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/ApiKeyRateLimited"
  /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"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
    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.


        **Limits (422 `unprocessable`).** A tenant may hold at most 20
        **enabled**

        endpoints (every event writes one delivery row per endpoint); the
        catalog key

        `webhook_max_endpoints` raises it per tenant. Disabled endpoints do not
        count,

        and a tenant already above the cap keeps delivering to the ones it has.


        `url` MUST be an `https://` URL **on port 443** whose host resolves only
        to

        PUBLIC addresses — a private IP literal, loopback, RFC1918, CGNAT,
        link-local /

        cloud metadata and a name that does not resolve are refused **400**

        naming `url` (SSRF guard, fail-closed). The guard judges the resolved

        addresses only: a `.internal` or cluster name is refused because of
        where

        it resolves, never because of its suffix. 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`, `disposition.event`,
        `carrier.health_changed`, `anomaly.detected`, `ai.drift.detected`,
        `port_request.ported`); 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.


        **Role.** Writing the registry needs a `dd_` key or a dashboard login
        with

        the `account_admin` role. A `member` login is **403 `role_forbidden`**

        (before the body is validated; nothing is written) once the tenant's

        catalog key `webhook_admin_role_enforced` is on. Born off: until then a

        `member` writes as before and the platform only counts it

        (`dialer.webhooks.admin_role_would_deny`).


        A `test` API key (core#2965) is refused with `403 test_environment`:
        this verb sets the secret or the

        destination of events of a live endpoint, and the row is left untouched.
      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"
        "422":
          $ref: "#/components/responses/Unprocessable"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /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"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
    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**.


        `enabled: true` counts as a creation: past `webhook_max_endpoints`
        enabled

        endpoints it is **422**.


        **Role.** Writing the registry needs a `dd_` key or a dashboard login
        with

        the `account_admin` role. A `member` login is **403 `role_forbidden`**

        (before the body is validated; nothing is written) once the tenant's

        catalog key `webhook_admin_role_enforced` is on. Born off: until then a

        `member` writes as before and the platform only counts it

        (`dialer.webhooks.admin_role_would_deny`).


        A `test` API key (core#2965) is refused with `403 test_environment`:
        this verb sets the secret or the

        destination of events of a live endpoint, and the row is left untouched.
      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"
        "422":
          $ref: "#/components/responses/Unprocessable"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
    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.


        **Role.** Writing the registry needs a `dd_` key or a dashboard login
        with

        the `account_admin` role. A `member` login is **403 `role_forbidden`**

        (before the body is validated; nothing is written) once the tenant's

        catalog key `webhook_admin_role_enforced` is on. Born off: until then a

        `member` writes as before and the platform only counts it

        (`dialer.webhooks.admin_role_would_deny`).
      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"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /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.


        **Role.** Writing the registry needs a `dd_` key or a dashboard login
        with

        the `account_admin` role. A `member` login is **403 `role_forbidden`**

        (before the body is validated; nothing is written) once the tenant's

        catalog key `webhook_admin_role_enforced` is on. Born off: until then a

        `member` writes as before and the platform only counts it

        (`dialer.webhooks.admin_role_would_deny`).


        A `test` API key (core#2965) is refused with `403 test_environment`:
        this verb sets the secret or the

        destination of events of a live endpoint, and the row is left untouched.
      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"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /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"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
  /v1/webhooks/{id}/test:
    post:
      operationId: testWebhook
      tags:
        - webhooks
      summary: Send one test event to the endpoint
      description: >
        Sends ONE `webhook.test` to the endpoint **now** and answers with what
        it

        replied. The request is the one production sends — same signature

        (`x-dd-signature`, plus `x-dd-signature-previous` inside a rotation
        window),

        same headers, same egress guard — so a receiver that verifies this
        verifies

        the real events. The body is the v1 envelope with `event:
        "webhook.test"` and a

        minimal synthetic `data`: `{"test": true, "webhook_id": …, "sent_at":
        …}`.

        `webhook.test` is a system event outside the subscribable catalog: it is
        never

        a real event with invented data, so a consumer cannot act on it by
        mistake.


        The test is **evidence**: the delivery and its single attempt are
        appended to

        `GET /v1/webhooks/{id}/deliveries` (`event: "webhook.test"`). It is
        judged once —

        no retries, no circuit breaker — never counts in the attempt counters,
        and

        cannot be redelivered (**404** on `…/redeliver`). A non-2xx answer or a

        transport failure is still **200** here: the outcome is in `status`

        (`delivered` | `deadletter`) and `attempt.reason`.


        **Limit.** At most 5 tests per webhook per minute; the sixth is **429

        `rate_limited`** with `Retry-After`. A **disabled** endpoint is **409**.


        **Role.** Same as the rest of the registry: a `dd_` key or a dashboard
        login

        with the `account_admin` role; a `member` is **403 `role_forbidden`**
        once the

        tenant's catalog key `webhook_admin_role_enforced` is on. No request
        body.
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: The attempt that was made.
          content:
            application/json:
              schema:
                type: object
                required:
                  - delivery_id
                  - event
                  - status
                  - attempt
                  - latency_ms
                properties:
                  delivery_id:
                    type: string
                    format: uuid
                  event:
                    type: string
                    enum:
                      - webhook.test
                  status:
                    type: string
                    enum:
                      - delivered
                      - deadletter
                  attempt:
                    $ref: "#/components/schemas/WebhookDeliveryAttempt"
                  latency_ms:
                    type: integer
                    minimum: 0
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "429":
          description: "`rate_limited`: more than 5 tests of this webhook in the last
            minute. Wait `Retry-After` seconds. The key's own quota
            (`ApiKeyRateLimited`) can also answer 429 here."
          headers:
            Retry-After:
              $ref: "#/components/headers/RetryAfter"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
  /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"
        "429":
          $ref: "#/components/responses/ApiKeyRateLimited"
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 (it carries the message's
        `thread_id`,

        empty if it formed none, and never the body). 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`; `disposition.event` → `DispositionEventData` (the
        `emit_event` effect of a

        disposition rule); `carrier.health_changed` → `TrunkHealthEvent` (a
        state transition of a

        BYOC trunk, never the `confirm` row); `anomaly.detected` →
        `AnomalyDetectedData` (the

        evidence of an insights anomaly the moment it opens; never on
        resolution);

        `ai.drift.detected` → `AiDriftDetectedData` (the evidence of a voice-AI
        drift alert the

        moment it opens; never when it clears); `port_request.ported` →
        `PortRequest` (the request at the

        moment it completes, `status: completed`). `event_id` is the receiver's
        idempotency key: the same fact is

        never announced twice to the same endpoint. Dedupe on the `event_id` of
        the signed

        body, NOT on the `x-dd-delivery-id` header: that header carries the
        delivery row id,

        travels outside the signature (`x-dd-signature` covers the body) and is
        not an

        idempotency key.
      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.
    supportSessionToken:
      type: http
      scheme: bearer
      bearerFormat: dds_ support-session token
      description: >
        READ-ONLY support session over ONE tenant (`Authorization: Bearer
        dds_...`,

        ADR #225, core#1765), opened by a platform operator with a personal
        `ddo_`

        token through `POST /v1/admin/tenants/{id}/support-sessions` and only
        while

        the tenant's `account_admin` has a live consent grant. Only the sha256
        is

        stored. It is resolved against the database on EVERY request (session,

        grant, tenant flag, tenant and account status, the operator's own
        token): a

        revoked or expired session is `401` on the very next call, a suspended

        tenant `403`, and a database that cannot answer `503`, never a pass.


        The lane is DEFAULT-DENY: it reaches only the `GET`/`HEAD` routes that

        declare this scheme (`403` for anything else, written to the customer's

        trail as `request_denied`), and every transaction it opens is

        `READ ONLY`. Each request is recorded in the customer's trail BEFORE it
        is

        served; if the record cannot be written the answer is

        `503 audit_unavailable` and nothing is served. Every response of the
        lane

        carries `X-Dialer-Support-Session: read-only`.
    adminOperatorToken:
      type: http
      scheme: bearer
      bearerFormat: ddo_ personal platform-operator token
      description: >
        PERSONAL platform-operator token (`Authorization: Bearer ddo_...`, ADR
        #154)

        for the `/v1/admin/*` surface — a separate trust domain that predates
        any

        tenant, with a person behind each credential. Each token belongs to one

        named operator with a role (`operator` or `founder`), an expiry and a

        revocation state; only its sha256 is stored (`platform_operator_tokens`,

        RLS-protected, append-only). It is verified against the database on

        EVERY request, so a revoked or expired token is `401` on the very next

        call. Everything it writes is signed `operator:<id> (<name>)` in the

        audit trails (`config-audit`, plan events, manual payments,
        legal-posture

        seal). Issued and revoked only by a release command

        (`Dialer.Release.issue_operator_token/1`, `revoke_operator_token/2`),

        never over the network; the plaintext is shown once, at issuance. A

        `ddo_` token is judged only as a personal token. The shared bearer

        (`ADMIN_API_TOKEN`) was retired in core#2196: any other bearer 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.
    publicSiteKey:
      type: http
      scheme: bearer
      bearerFormat: ddc_ publishable site key
      description: >
        PUBLISHABLE site key (`Authorization: Bearer ddc_...`, ADR 220) minted
        by

        `POST /v1/click-to-call/sites` and embedded in the customer's web page.
        It

        is NOT a secret: it opens exactly two routes (`GET

        /v1/public/call-request-config`, `POST /v1/public/call-requests`) and

        nothing else, and no other bearer (`dd_`, `ddu_`, `ddw_`) opens them. A

        mandatory `Origin` header is value-matched against the configured

        widget-host origins. Only the sha256 is stored. `401` for

        missing/unknown/revoked keys; `403` for a non-allowed Origin or a

        suspended tenant; `404` (the router's generic not-found) while the lane

        is off for the deployment or the tenant.
    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.
  headers:
    RetryAfter:
      description: |
        Whole seconds (at least 1) to wait before retrying (RFC 9110 §10.2.3,
        delay-seconds). Only sent where the refusing engine KNOWS when capacity
        returns.
      schema:
        type: integer
        minimum: 1
      example: 12
    RateLimitLimit:
      description: Requests per minute this API key may spend (the effective
        per-tenant limit).
      schema:
        type: integer
        minimum: 1
      example: 3000
    RateLimitRemaining:
      description: Requests this API key can still send right now without being refused.
      schema:
        type: integer
        minimum: 0
      example: 2999
    RateLimitReset:
      description: Whole seconds until this API key's budget is full again.
      schema:
        type: integer
        minimum: 1
      example: 1
  parameters:
    AiAgentExperimentId:
      name: experiment_id
      in: path
      required: true
      description: Id of an A/B experiment of the AI agent.
      schema:
        type: string
        format: uuid
    ExperimentId:
      name: experiment_id
      in: path
      required: true
      description: Id of an A/B experiment of the campaign.
      schema:
        type: string
        format: uuid
    PathId:
      name: id
      in: path
      required: true
      description: Resource UUID. Malformed or cross-tenant ids read as `404`.
      schema:
        type: string
        format: uuid
    ImportMappingIdQuery:
      name: mapping_id
      in: query
      required: false
      description: >
        The import-mapping template the file was mapped with. It is resolved
        BEFORE

        anything is imported (`404` when it is not the tenant's, `409` when it
        is

        archived: nothing is written) and the version used is recorded after the

        import (`GET /v1/import-mappings/{id}/uses`). On the multipart form of

        `POST /v1/debts/import` the template replaces the `mapping` and
        `separator`

        parts. Absent, the import is unchanged.
      schema:
        type: string
        format: uuid
    ImportMappingVersionQuery:
      name: mapping_version
      in: query
      required: false
      description: The version of `mapping_id` to record. Default the current one.
        Needs `mapping_id`.
      schema:
        type: integer
        minimum: 1
    ImportMappingImportIdQuery:
      name: import_id
      in: query
      required: false
      description: >
        Names ONE import that the client sends in several requests (chunks). The

        uses those requests leave read back in `GET
        /v1/import-mappings/{id}/uses`

        as a single use — counts added up, `id` = this `import_id` — as long as

        they share the version and the list. Needs `mapping_id` (`400`
        otherwise,

        and for a value that is not a UUID). Absent, each request is its own
        use.
      schema:
        type: string
        format: uuid
    DebtListCampaignId:
      name: campaign_id
      in: path
      required: true
      description: The campaign's 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
    DebtsInclude:
      name: include
      in: query
      description: >
        Extra projections to add to every row, comma-separated
        (`include=dialability`);

        `include[]=dialability` is accepted too. `dialability` (ADR #269): why
        the

        account is not dialable and each phone's consent, evaluated now with the

        dialer's own gates, opt-in and capped at a page of 50. An unknown value
        is a

        400, never ignored.
      style: form
      explode: false
      schema:
        type: array
        items:
          type: string
          enum:
            - dialability
    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 engine

        values — `answered_human`, `abandoned`, `delivered`, `ai_conversation`,

        `answered_machine`, `ai_stream_lost`, `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:
    ApiKeyRateLimited:
      description: >
        This API key is over its request quota, or the tenant's keys together
        are

        over the tenant ceiling (core#2503) (`code` `rate_limited`). The

        request was not processed. Wait `Retry-After` seconds and retry; a
        refusal

        is not counted against the budget, so that wait is always enough.
        Bounded

        per API key and, above the keys, per tenant
        (`api_tenant_requests_per_minute`):

        a sibling key is unaffected until the tenant's keys together reach that
        ceiling. The quota

        applies to every operation reachable with a `dd_` key, so the operations

        that name a more specific `429` (call control, dispositions, SMS, …)

        can ALSO answer this one with its `X-RateLimit-*` headers.
      headers:
        Retry-After:
          $ref: "#/components/headers/RetryAfter"
        X-RateLimit-Limit:
          $ref: "#/components/headers/RateLimitLimit"
        X-RateLimit-Remaining:
          $ref: "#/components/headers/RateLimitRemaining"
        X-RateLimit-Reset:
          $ref: "#/components/headers/RateLimitReset"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
          example:
            error:
              code: rate_limited
              message: API key request quota exceeded; retry after the interval in Retry-After
    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).

        A dashboard session (`ddu_`) that a newer login of the same person ended
        answers the code `session_superseded`

        instead (tenant flag `single_session_enforced`), so the screen can say
        why it was signed out; a logout, an

        expired or an unknown token stay `unauthorized`.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
          example:
            error:
              code: unauthorized
              message: invalid or revoked API key
    Forbidden:
      description: >
        The tenant is suspended. A dashboard session (`ddu_`) of a tenant whose
        login policy (ADR 227) is in mode

        `enforce` answers `403` with the code `login_policy_denied` on a request
        from an address or at an hour the

        policy does not allow — the same body for both; the session is not
        revoked and a later request inside the

        policy goes through. A widget token (`ddw_`) of a seat is held to the
        same policy on every

        request (core#2406, flag `widget_login_honors_policy`). A synthetic
        (demo) tenant answers `403` with the code `synthetic_tenant` on every
        route

        that would reach a third party — manual dial, external transfer or
        conference leg, SMS send (core#2251,

        ADR #280 addendum). No payment changes it.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
          example:
            error:
              code: forbidden
              message: tenant is suspended
    VerificationRequired:
      description: >
        The account is a 1-seat account that has not been verified, and the
        campaign runs

        without an agent (`code` `verification_required`). Campaigns with an
        agent are not

        affected; the account is verified when the platform stamps its identity
        check

        (`tenants.kyc_verified_at`).
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
          example:
            error:
              code: verification_required
              message: this account must be verified before it can create or start a campaign
                that runs without an agent
    ApiKeyAdminForbidden:
      description: >
        Minting or revoking an API key was refused (core#2359, ADR #46
        addendum). `code` `role_forbidden`:

        the caller is a dashboard login without the `account_admin` role, or a
        `dd_` key in a tenant that

        already has an active `account_admin` (core#2449; founder knob
        `api_key_manages_people`, default off);

        the per-tenant knob `api_key_admin_only` (default on) turns this off.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
          example:
            error:
              code: role_forbidden
              message: minting or revoking an API key takes a dashboard login with the
                account_admin role; a dd_ key may do it only while the tenant
                has no account_admin
    ApiKeyLimit:
      description: >
        The tenant already holds the maximum number of LIVE API keys (core#2503;
        per-tenant knob

        `api_keys_max_live`, default 50). `code` `api_key_limit`. Nothing was
        minted; revoke a key

        (`DELETE /v1/api_keys/{id}`) and retry.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
          example:
            error:
              code: api_key_limit
              message: this tenant already holds the maximum number of live API keys; revoke
                one first
    AgentCredentialForbidden:
      description: >
        Setting or clearing a seat's widget-login password was refused
        (core#2404, ADR #126 addendum). `code`

        `role_forbidden`: the caller is a dashboard login that is neither an
        `account_admin` nor the login linked

        to that seat (the route takes a `dd_` key, an `account_admin` login or
        the seat's own login); the

        per-tenant knob `agent_credential_admin_only` (default on) turns this
        off. Any other `403` is the plain

        scope refusal (`forbidden`: a `ddw_` or a public-site key).
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
          example:
            error:
              code: role_forbidden
              message: setting or clearing a seat's widget password takes a dd_ key, a
                dashboard login with the account_admin role, or the seat's own
                login
    UserManagementForbidden:
      description: >
        A user-management write was refused (core#2334, ADR #46 addendum).
        `code` `admin_target_forbidden`:

        a `member` login tried to disable or re-enable an `account_admin` —
        always, whatever the tenant's

        settings. `code` `role_forbidden`: the tenant has an active
        `account_admin` and the caller is a

        `member` login or (core#2449) a `dd_` key (the route takes an
        `account_admin` login; founder knob

        `api_key_manages_people`, default off, restores the key); the per-tenant
        knob

        `user_management_admin_only` (default on) turns this one off, and a
        tenant with no active

        `account_admin` is not asked for one. `code` `forbidden`: the tenant is
        suspended or the token is a

        widget token.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
          example:
            error:
              code: role_forbidden
              message: managing users takes a dashboard login with the account_admin role
    AiAgentWriteForbidden:
      description: >
        An AI agent write (`POST /v1/ai/agents`, `PATCH` or `DELETE
        /v1/ai/agents/{id}`) was refused.

        `code` `role_forbidden`: the caller is a `member` login (the route takes
        a `dd_` key or an

        `account_admin` login). `code` `forbidden`: the tenant is suspended or
        the token is a widget token.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
          example:
            error:
              code: role_forbidden
              message: creating an AI agent takes a dd_ key or a dashboard login with the
                account_admin role
    DisclosureScriptWriteForbidden:
      description: >
        A script write was refused. `code` `role_forbidden`: the caller is a

        `member` login (the route takes a `dd_` key or an `account_admin`
        login).

        `code` `forbidden`: the tenant is suspended or the token is a widget
        token.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
          example:
            error:
              code: role_forbidden
              message: approving a disclosure script takes a dd_ key or a dashboard login with
                the account_admin role
    QmWriteForbidden:
      description: >
        A QM write was refused. `code` `qm_not_enabled`: the tenant's
        `qm_enabled`

        flag is off (it is off by default; a flag that cannot be read counts as

        off). `code` `role_forbidden`: the caller is not a dashboard login with

        the `account_admin` role (a `dd_` key, a `member`). `code` `forbidden`:

        the tenant is suspended or the token is a widget token.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
          example:
            error:
              code: qm_not_enabled
              message: QM is not enabled for this tenant
    QmEvaluateForbidden:
      description: >
        Evaluating a recording was refused. `code` `qm_not_enabled`: the
        tenant's

        `qm_enabled` flag is off. `code` `role_forbidden`: the caller is not an

        eligible evaluator (a `dd_` key, a `member`, a seat that is not
        supervisor or

        admin, an inactive seat). `code` `self_evaluation_forbidden`: the
        recording is

        the caller's own. `code` `forbidden`: the tenant is suspended or the
        token is a

        widget token.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
          example:
            error:
              code: role_forbidden
              message: evaluating a recording takes a supervisor or admin seat, or an
                account_admin login
    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 is closer than the minimum notice for a callback (10
                minutes unless the tenant's callback_min_notice_seconds says
                otherwise)
    AiAgentExperimentUnprocessable:
      description: >
        The body parsed but is not a legal experiment of an AI agent (2 to 5
        variants,

        exactly one control, weights 1..1000, overrides inside the closed list,
        a

        `voice_id` of the voice catalog, a `model` of the model catalog, a
        one-line

        `prompt_style` of up to 600 characters). Every refused field is in

        `error.details`, each with its path (`variants[1].overrides.model`, or

        `variants[1].overrides` for a key outside the list, named in `message`);
        nothing

        was stored.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ExperimentErrorEnvelope"
          example:
            error:
              code: unprocessable
              message: "variants[1].overrides has keys outside the closed list: disclosure;
                allowed: voice, prompt_style, model, llm"
              details:
                - path: variants[1].overrides
                  message: "has keys outside the closed list: disclosure; allowed: voice,
                    prompt_style, model, llm"
    ExperimentUnprocessable:
      description: >
        The body parsed but is not a legal experiment (2 to 5 variants, exactly

        one control, weights 1..1000, overrides inside the closed list and the

        campaign's own rules). Every refused field is in `error.details`, each

        with its path (`variants[1].overrides.window`, or
        `variants[1].overrides` for a key outside the list, named in `message`);
        nothing was stored.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ExperimentErrorEnvelope"
          example:
            error:
              code: unprocessable
              message: "variants[1].overrides has keys outside the closed list: optout_digit;
                allowed: script, window, min_gap_seconds, email_template_id"
              details:
                - path: variants[1].overrides
                  message: "has keys outside the closed list: optout_digit; allowed: script,
                    window, min_gap_seconds, email_template_id"
    CampaignWeeklyWindowRefused:
      description: |
        `calling_window_weekly` is malformed or reaches past the day (see
        `CampaignWrite.calling_window_weekly`); nothing was stored. Any other
        field in error is named in the same message.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
          example:
            error:
              code: unprocessable
              message: calling_window_weekly mon needs exactly start and end (HH:MM or
                HH:MM:SS) with start before end; a day's window does not cross
                midnight, and the day ends at 23:59:59 (24:00 is not a time of
                day)
    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:
        the

        send throttle does not know when capacity returns, and neither does the

        spend cap (a cap raise or the month rollover). Over the per-key quota
        this

        operation answers `rate_limited` as in `ApiKeyRateLimited`.
      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: the throttle does not know when capacity returns. Over the per-key

        quota this operation answers `rate_limited` as in `ApiKeyRateLimited`.
      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. Every braked verb — the four

        toggles and the blind transfer — shares 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. `Retry-After` is the time
        left in the fixed window.
      headers:
        Retry-After:
          $ref: "#/components/headers/RetryAfter"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
          example:
            error:
              code: rate_limited
              message: call control rate limit exceeded; retry later
    ImportThrottled:
      description: >
        The import was refused BEFORE any row was read (core#2505). Two codes,
        both with

        `Retry-After`. `import_in_progress`: the tenant already has its maximum
        of imports

        running (2 by default, per node); retry when one finishes.
        `rate_limited`: more

        than 10 imports in the fixed window (by default) for the tenant, which
        is a bound

        per tenant and not per key because the connection pool an import drains
        is the

        cell's. Applies to `POST /v1/debts/import`, `POST
        /v1/debt-lists/{id}/import` and

        `POST /v1/consents/import` alike. The per-key request quota can still
        answer `rate_limited` first.
      headers:
        Retry-After:
          $ref: "#/components/headers/RetryAfter"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
          examples:
            in_flight:
              value:
                error:
                  code: import_in_progress
                  message: an import of this tenant is still running; retry when it finishes
            window:
              value:
                error:
                  code: rate_limited
                  message: import rate limit exceeded; retry later
    TranscriptSearchThrottled:
      description: >
        More than 30 searches (by default) in the fixed window for this
        dashboard user

        (core#2505). `code` `rate_limited`, with `Retry-After`. The budget is
        the

        person's, so a colleague of the same tenant is not held by it. A refusal
        by role

        (`403`) does not spend it.
      headers:
        Retry-After:
          $ref: "#/components/headers/RetryAfter"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
          example:
            error:
              code: rate_limited
              message: transcript search 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.
        `Retry-After`

        is the time left in the fixed window.
      headers:
        Retry-After:
          $ref: "#/components/headers/RetryAfter"
      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.
        `Retry-After`

        is the time left in the fixed window.
      headers:
        Retry-After:
          $ref: "#/components/headers/RetryAfter"
      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 once it completes, but

        while any confirmation is being verified it holds one slot of the
        budget,

        so simultaneous requests can never get more verifications than the
        budget

        (core#1114); a burst larger than the budget answers `429` to the excess

        even if some of it carried the right password. `Retry-After` is the time
        left

        in the fixed window.
      headers:
        Retry-After:
          $ref: "#/components/headers/RetryAfter"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
          example:
            error:
              code: rate_limited
              message: too many failed password confirmations; retry later
    MfaVerifyThrottled:
      description: >
        Too many FAILED second-factor attempts for this USER inside the fixed
        window

        (retryable later). `code` `rate_limited`. Keyed on the person, never on
        a

        network address, evaluated BEFORE the code is compared, and SHARED by

        `POST /v1/auth/mfa/verify`, the forced-enrollment routes

        (`/v1/auth/mfa/enroll*`) and every `/v1/me/mfa/*` route that compares a

        code or password. A correct attempt is not counted once it completes;
        while

        one is being verified it holds a slot, so simultaneous requests never
        get

        more comparisons than the budget (ADR #144). `Retry-After` is the time
        left

        in the fixed window.


        It is also the answer of the CUMULATIVE brake (core#1572): once a person

        piles up `mfa_progressive_threshold_failures` refused codes (default 20)

        inside `mfa_progressive_window_seconds` (default 24 h), a TOTP is not
        even

        compared until `mfa_progressive_cooldown_seconds` (default 15 min) have

        passed since the last failure, and `Retry-After` is that wait. A
        recovery

        code is never held by it, and an `account_admin` can lift it

        (`POST /v1/users/{id}/mfa/reset-failures`). It is not a lockout: the
        person

        is slowed, not shut out.
      headers:
        Retry-After:
          $ref: "#/components/headers/RetryAfter"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
          example:
            error:
              code: rate_limited
              message: too many failed second-factor attempts; 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`.
        `Retry-After`

        is the time left in the fixed window.


        `/v1/auth/login` has a second, CUMULATIVE reason for the same `429`
        (core#2335,

        NIST SP 800-63B-4 §3.2.2): once a person accumulates

        `login_progressive_threshold_failures` (default 20) wrong passwords
        since

        their last successful sign-in, inside 24 h, the login is held even for
        the

        CORRECT password for 15 min after the last failure, doubling for every
        further

        threshold-many failures (capped at 6 h); `Retry-After` is the time left.
        A

        pause, never a permanent lock. Held attempts are not recorded, so they
        do not

        lengthen it.
      headers:
        Retry-After:
          $ref: "#/components/headers/RetryAfter"
      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
    WidgetSipUnavailable:
      description: >
        The SIP credential could not be provisioned (`code` `sbc_unavailable`),
        so

        NOTHING was returned. FAIL-CLOSED and retryable: there is no fallback to

        the agent's long-lived `agentcred`, because handing the frame a

        credential the server did not intend to grant is worse than a frame with

        no audio.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
          example:
            error:
              code: sbc_unavailable
              message: widget sip provisioning unavailable, retry
    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.


        `listen`, `whisper` and `barge` can also be `429` with `code`
        `rate_limited` and

        a `Retry-After` header (core#2726): more than 60 attempts per window and

        principal. `takeover` is exempt.
      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
    SeatFromToken:
      description: >
        The seat could not be derived from the token, or the body contradicted
        it

        (core#1033, ADR #131). `code` `seat_not_linked`: this login has no
        active

        agent seat linked to it (`users_agents.user_id`), or the seat the token
        was

        bound to is no longer active — an administrator links the seat, the
        caller

        cannot pick one. `code` `seat_mismatch`: `sip_extension` named a seat
        that is

        not the token's own; the message deliberately does NOT reveal which seat
        is,

        so the refusal is not an oracle for colleagues' extensions. A derived
        seat

        that has NO `sip_extension` yet is `409` instead (the token and the seat
        are

        both right; what is missing is a roster field only an administrator can
        fill).
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
          example:
            error:
              code: seat_mismatch
              message: sip_extension does not match the seat this token owns
    SupervisionRoleForbidden:
      description: >
        The caller may not supervise with that `supervisor_ext` (ADR #116,
        core#928;

        ADR #135, core#1037 phase 3). `code` `role_forbidden`: the extension
        resolves

        to an active agent whose `role` is not `supervisor` or `admin` — or a
        `ddu_`

        login without a linked seat named somebody else's extension without the

        `account_admin` role (once `ddu_author_role_enforced` is on). `code`

        `forbidden`: a principal bound to ONE agent — a `ddw_` token, or a
        `ddu_`

        login linked to a seat — 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 extension's role check off (the identity binding and the

        principal rule stay).
      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
    TransferThrottled:
      description: |
        `code` `rate_limited`: the NR-18 request-rate brake, exactly as
        `CallControlThrottled` describes it (refused before the call is looked
        up). `code` `carrier_cps`: an EXTERNAL transfer whose leg would leave by
        a carrier that is already at its contracted calls-per-second this
        second. Nothing was written or dialled and the consumer is still with
        their agent. Retryable within a second. `rate_limited` carries
        `Retry-After` (time left in the fixed window); `carrier_cps` does not.
        `code` `tenant_channels` / `tenant_cps` (conference add to an EXTERNAL
        number only): the third party is one more channel of the tenant, which
        is at its channel ceiling or call-rate window; nothing was dialled and
        the call goes on with the people already on it.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
          examples:
            rate_limited:
              value:
                error:
                  code: rate_limited
                  message: call control rate limit exceeded; retry later
            carrier_cps:
              value:
                error:
                  code: carrier_cps
                  message: the carrier for the transferred leg is at its contracted call rate;
                    retry
            tenant_channels:
              value:
                error:
                  code: tenant_channels
                  message: the tenant is at its channel or call-rate ceiling; the third party was
                    not dialled
    TransferUnavailable:
      description: |
        The four `503` codes of a blind transfer. `audit_unavailable` and
        `esl_unavailable` are the ones `CallControlUnavailable` describes.
        Two more apply to an EXTERNAL destination only. `no_route`: the house
        leg has no directed carrier route (routing switched off, no candidate
        carrier), so it is not placed, because house traffic is always directed.
        `capacity_unavailable`: the capacity gate could not admit the leg's
        carrier, and it fails closed. `spend_guard_unavailable` (core#2846): the
        spend guard could not judge the leg, and it fails closed. In every case
        nothing was written or dialled.
      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
            no_route:
              value:
                error:
                  code: no_route
                  message: no directed carrier route for the transferred leg; nothing was dialled
            capacity_unavailable:
              value:
                error:
                  code: capacity_unavailable
                  message: the capacity gate could not admit the transferred leg, retry
    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
    TransferRefused:
      description: >
        The blind transfer never reached the switch. Three families of refusal

        share this status, and `gate` (when present) says which checkpoint

        refused.


        An EXTERNAL leg with no sell rate (`code` `no_rate`, core#2846) is
        refused

        here too: topping up changes nothing, so it is not a `402`.


        **The GATE_MID_CALL re-check refused it** — `code` `compliance_blocked`,

        `gate` `mid_call`, with the same envelope `unhold` returns

        (`reason_code`, `rule_id`, `auto_hangup_at`): consent revoked, a written

        cease-and-desist on file, or the frozen consumer timezone(s) outside the

        quiet-hours window. A transfer puts the consumer back on live audio just

        like an unhold, so it is gated like one; otherwise a blocked unhold

        could be walked around by transferring the same call. The C9 auto-hangup

        is armed.


        **The GATE_PRE_DIAL chain refused the NEW LEG** — `code`

        `compliance_blocked`, `gate` `pre_dial`, with the engine `decision_id`

        and `rule_id`. Only an EXTERNAL destination can earn this: the leg it

        would create is an origination and passes the same chain a dial does,

        evaluated against the DESTINATION number (its DNC status, its NPA's

        quiet-hours window). NOTHING was dialled, NOTHING is armed and the

        consumer's own call is untouched — they are still talking to their

        agent. `reason_code` `engine_unavailable` is the fail-closed answer when

        the gate could not be evaluated at all: a gate that cannot decide

        blocks. `reason_code` `destination_unregistered` (`rule_id`

        `transfer.destination_unregistered.v1`) is the refusal of an E.164 the

        tenant never registered in `/v1/transfer-destinations`, and

        `destination_registry_unavailable` the fail-closed answer when that

        registry could not be read; both come before the chain, with no

        `decision_id`.

        `reason_code` `destination_not_allowed` (`rule_id`

        `egress.destination_not_allowed.v1`) is the refusal of an E.164 outside

        the destination allowlist of a BYOC trunk of the sede (core#1281), and

        `destination_allowlist_unavailable`

        (`egress.destination_allowlist_unavailable.v1`) the fail-closed answer

        when that allowlist could not be read; both come before the registry,

        with `decision_id` when their `gate_blocks` row was written.

        `reason_code` `destination_class_blocked` (`rule_id`

        `egress.destination_class_blocked.v1`) is the refusal of an E.164 whose

        longest current prefix is a destination class (`premium`, `satellite`,

        `high_cost`, `irsf`, core#2844) the tenant has not unlocked, and

        `destination_policy_unavailable`

        (`egress.destination_policy_unavailable.v1`) the fail-closed answer when

        that policy could not be read (core#2893); both come right after the

        allowlist and before the registry, with `decision_id` when their

        `gate_blocks` row was written.


        **Or the destination itself was refused** — envelope `ErrorEnvelope`.

        `code` `destination_not_supported`: the destination is number-shaped but

        not strict E.164 (bare national digits, a malformed `+` string).

        External destinations are dialled from E.164 only, because jurisdiction

        is resolved from that number. `code` `destination_not_found`: anything

        else that does not resolve to an active roster agent of THIS tenant. The

        split is decided by the SHAPE of what was sent, never by a second

        lookup, so neither answer says whether the destination exists in another

        tenant. `code` `destination_unavailable`: the destination IS an active

        agent of this tenant, but their seat is not `:available` right now

        (ringing, on a call, in wrap-up, paused, or not checked in). This phase

        does not move the seat, so transferring onto a busy agent would

        double-book them; this answer only ever names an agent of the caller's

        OWN roster, so it is not a cross-tenant oracle either. `code`

        `caller_id_unavailable`: an EXTERNAL transfer on a call that has no

        tenant DID to present — refused rather than dialled with the carrier

        gateway's default ANI, which the tenant may not own.

        `code` `did_unverified` (core#2493): the DID the external leg would

        present (on an inbound call, the number the consumer dialled) is a

        `customer` DID whose ownership was never verified, and the tenant key

        `did_ownership_proof` is `enforce`. With `observe` the leg goes out and

        `dialer_dids_unverified_use_total{surface="voice_transfer"}` counts it;

        with `off` nothing changes. Applies to an external blind transfer and to

        an external conference add.


        In every case the `call_control_actions` row settles `failed` with the

        requested destination recorded; ZERO ESL commands ran and the call is

        untouched.


        **Or the consultation was never taken** (attended transfer only) —

        `code` `consultation_unanswered`: the destination is not registered, is

        busy, or did not answer the consultation leg. It is a `422` and not a

        `502` because the switch worked and a person did not pick up. THE HOLD

        HAS ALREADY BEEN RELEASED: the consumer is back with their agent before

        this response is written, and no consultation is left open.


        **Or the conference leg was never taken** (conference add only) — `code`

        `conference_unanswered`: the colleague is not registered, is busy, or
        did

        not answer. A `422` and not a `502` for the same reason; the call

        continues exactly as it was and no conference is left open.
      content:
        application/json:
          schema:
            anyOf:
              - $ref: "#/components/schemas/MidCallComplianceBlockedError"
              - $ref: "#/components/schemas/PreDialComplianceBlockedError"
              - $ref: "#/components/schemas/ErrorEnvelope"
          examples:
            destination_not_found:
              value:
                error:
                  code: destination_not_found
                  message: no active agent with that destination in this tenant
            destination_not_supported:
              value:
                error:
                  code: destination_not_supported
                  message: an external transfer destination must be E.164 (+<country><number>)
            destination_unavailable:
              value:
                error:
                  code: destination_unavailable
                  message: that agent is not available to take a transfer right now
            caller_id_unavailable:
              value:
                error:
                  code: caller_id_unavailable
                  message: this call has no tenant caller-ID to present on a transferred leg
            did_unverified:
              value:
                error:
                  code: did_unverified
                  message: the caller-ID this transferred leg would present is not a verified
                    number of the tenant
            compliance_blocked_mid_call:
              value:
                error:
                  code: compliance_blocked
                  message: the mid-call compliance re-check refused this blind_transfer
                    (consent_revoked)
                  gate: mid_call
                  reason_code: consent_revoked
                  rule_id: regf.revocation.v1
            compliance_blocked_pre_dial:
              value:
                error:
                  code: compliance_blocked
                  message: the origination gate refused the leg this blind_transfer would create
                    (dnc_listed)
                  gate: pre_dial
                  reason_code: dnc_listed
                  rule_id: dnc.lists.v1
                  decision_id: 018f3f2a-0000-7000-8000-000000000000
    AiSandboxThrottled:
      description: >
        This tenant (or this sandbox session) has already used its quota of
        sandbox turns in

        the current window (`code` `rate_limited`). The runtime was not called.
        Wait

        `Retry-After` seconds and retry.
      headers:
        Retry-After:
          $ref: "#/components/headers/RetryAfter"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
          example:
            error:
              code: rate_limited
              message: too many sandbox turns; retry later
    SandboxUnavailable:
      description: >
        The AI runtime sandbox is unconfigured or unreachable (fail-closed — the
        runtime may not exist yet),

        or the inference spend brake refused the turn (`code`
        `sandbox_budget_unavailable`).
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
          example:
            error:
              code: sandbox_unavailable
              message: the AI runtime sandbox is not configured or unreachable
    VoicePreviewThrottled:
      description: >
        This tenant has already caused its quota of voice renders in the current
        window

        (`code` `rate_limited`). The request rendered nothing. Wait
        `Retry-After`

        seconds and retry; a request for a sample that is already rendered is
        never

        counted and never refused by this budget.
      headers:
        Retry-After:
          $ref: "#/components/headers/RetryAfter"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
          example:
            error:
              code: rate_limited
              message: too many voice samples rendered; retry later
    VoicePreviewUnavailable:
      description: >
        The sample could not be produced right now (`code`
        `voice_preview_unavailable`).

        `error.reason` names the failing stage and never carries the provider's
        body,

        endpoint or credentials: `renderer_unconfigured`,
        `renderer_unreachable`,

        `renderer_http_<status>`, `renderer_empty_audio`, `render_failed` or

        `store_unavailable`. A failed render stores nothing and a retry renders
        again;

        after `store_unavailable` the audio is already stored and a retry is a
        cache hit.
      content:
        application/json:
          schema:
            type: object
            required:
              - error
            properties:
              error:
                type: object
                required:
                  - code
                  - message
                  - reason
                properties:
                  code:
                    type: string
                    const: voice_preview_unavailable
                  message:
                    type: string
                    description: Human-readable; never branch on it.
                  reason:
                    type: string
                    description: The failing stage (see the response description).
          example:
            error:
              code: voice_preview_unavailable
              message: the voice sample could not be produced right now
              reason: renderer_http_502
    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:
    ConsentRecord:
      type: object
      required:
        - number
        - status
        - channel
        - consented_at
        - source
        - evidence_ref
        - text_version
      properties:
        number:
          type: string
          description: Masked to its last 4 digits.
        status:
          type: string
          enum:
            - granted
        channel:
          type: string
          enum:
            - voice
            - sms
            - any
        consented_at:
          type: string
          format: date-time
        source:
          type: string
        evidence_ref:
          type: string
        text_version:
          type: string
          description: Empty when the writer named none.
    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
        status:
          type: string
          enum:
            - pending
            - available
            - purged
            - lost
          description: Where the audio is. `pending` = the call is recorded and the audio
            is not uploaded yet; `available` = uploaded and playable; `purged` =
            deleted at the end of its retention (the row survives as evidence);
            `lost` = the call ended, no upload ever confirmed and the object
            store holds no audio (for example the media pod died before the
            sidecar uploaded it). A `lost` row has no playback (`409`) and
            carries `lost_at` and `lost_reason`.
        lost_at:
          oneOf:
            - type: string
              format: date-time
            - type: "null"
          description: When the row was closed as `lost`. Kept if the audio arrived late
            and the row returned to `available`.
        lost_reason:
          oneOf:
            - type: string
              enum:
                - no_upload_after_call_end
                - no_upload_call_never_ended
            - type: "null"
          description: Why it was closed as `lost`; null when it never was.
        composed_from_parts:
          type: boolean
          description: "`true` when the audio was composed from the chunks the media pod
            uploaded WHILE the call lasted, because the whole file never arrived
            (for example the pod died mid-call). The row is `available` and
            playable, but the audio is TRUNCATED: read `audio_gap_ms`. `false`
            for a recording whose file arrived whole."
        audio_gap_ms:
          oneOf:
            - type: integer
              format: int64
              minimum: 0
            - type: "null"
          description: "The declared gap of a `composed_from_parts` recording, in
            milliseconds: the call duration per the CDR minus the audio actually
            composed (it includes the second or two the media server takes to
            start writing). null when the recording is not composed from parts,
            or the call never recorded an end and the gap cannot be computed."
        masked_secs:
          type: integer
          minimum: 0
          description: "Seconds of the recording that were MASKED (silenced on purpose by
            an agent, for example while the debtor dictates a card): the sum of
            the closed `masked_intervals`. 0 when the recording was never
            masked. A silence in the audio that falls inside a masked interval
            is not a silent line."
    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`).
            masked_intervals:
              type: array
              description: The stretches the recording was masked (times and the agent seat
                only, never content), oldest first. Empty when never masked. An
                interval still open on a live recording has `ended_at` null.
              items:
                $ref: "#/components/schemas/RecordingMaskedInterval"
            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.
    RecordingMaskedInterval:
      type: object
      required:
        - started_at
        - ended_at
      properties:
        started_at:
          type: string
          format: date-time
        ended_at:
          oneOf:
            - type: string
              format: date-time
            - type: "null"
          description: When the mask ended; null only while the recording is still live
            and masked.
        agent_id:
          oneOf:
            - type: string
              format: uuid
            - type: "null"
          description: The agent seat that masked; null when the actor was not an agent.
        closed_by:
          oneOf:
            - type: string
              enum:
                - resume
                - call_end
            - type: "null"
          description: "`resume` when the agent unmasked; `call_end` when the interval was
            still open as the recording closed and ended with the audio. null
            while open."
    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
    RecordingRedactionSegment:
      type: object
      required:
        - start_ms
        - end_ms
        - category
      properties:
        start_ms:
          type: integer
          minimum: 0
        end_ms:
          type: integer
        category:
          type: string
          enum:
            - card
            - ssn
            - bank_account
            - pin
    RecordingRedaction:
      type: object
      properties:
        recording_id:
          type: string
          format: uuid
        method:
          type: string
          enum:
            - silence
            - beep
        segments:
          type: array
          items:
            $ref: "#/components/schemas/RecordingRedactionSegment"
        redacted_ms:
          type: integer
        version:
          type: integer
        codec:
          type: string
        requested_by:
          type: string
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    RecordingTranscript:
      type: object
      required:
        - recording_id
        - redacted
        - provider
        - model
        - audio_seconds
        - segment_count
        - created_at
        - segments
      properties:
        recording_id:
          type: string
          format: uuid
        redacted:
          type: boolean
          description: "`true` for the default view (PII redacted); `false` only on the
            audited original."
        provider:
          type: string
          description: Speech-to-text provider that produced it.
        model:
          type: string
        language:
          type:
            - string
            - "null"
          description: Language the provider reported, when it reported one.
        audio_seconds:
          type: integer
          minimum: 0
          description: Seconds of audio billed.
        segment_count:
          type: integer
          minimum: 0
        created_at:
          type: string
          format: date-time
        segments:
          type: array
          items:
            type: object
            required:
              - seq
              - channel
              - start_ms
              - end_ms
              - text
            properties:
              seq:
                type: integer
                minimum: 0
              channel:
                type: integer
                minimum: 0
                description: "Audio channel: 0 or 1."
              start_ms:
                type: integer
                minimum: 0
              end_ms:
                type: integer
                minimum: 0
              text:
                type: string
    CallAnalysis:
      type: object
      required:
        - id
        - call_attempt_id
        - source
        - summary
        - suggestion_note
        - provider
        - model
        - prompt_version
        - created_at
      properties:
        id:
          type: string
          format: uuid
          description: Cite it as `suggestion_id` when typing.
        call_attempt_id:
          type: string
          format: uuid
        source:
          type: string
          enum:
            - transcript
            - ai_conversation
          description: Where the text came from, a recording transcript or a voice-AI
            conversation.
        summary:
          type: string
          maxLength: 1200
          description: Summary of the call. Personal data; never logged.
        suggested_code:
          type:
            - string
            - "null"
          description: A `code` ACTIVE in the tenant catalog, or null (see
            `suggestion_note`).
        suggested_class:
          type:
            - string
            - "null"
          enum:
            - contacted
            - no_contact
            - busy
            - machine
            - bad_number
            - do_not_call
            - other
            - null
          description: Class of `suggested_code` in the catalog today. `do_not_call` must
            never be preselected.
        suggestion_note:
          type: string
          enum:
            - suggested
            - model_none
            - not_in_catalog
            - inactive
          description: Why `suggested_code` is or is not set.
        confidence_pct:
          type:
            - integer
            - "null"
          minimum: 0
          maximum: 100
          description: The model's confidence; only present with a suggested code.
        provider:
          type: string
        model:
          type: string
        prompt_version:
          type: string
        created_at:
          type: string
          format: date-time
    TranscriptSearchPage:
      type: object
      required:
        - results
        - next_cursor
        - capped
      properties:
        results:
          type: array
          items:
            type: object
            required:
              - recording_id
              - started_at
              - agent_id
              - campaign_id
              - matches
            properties:
              recording_id:
                type: string
                format: uuid
              started_at:
                type: string
                format: date-time
              agent_id:
                type:
                  - string
                  - "null"
                format: uuid
              campaign_id:
                type:
                  - string
                  - "null"
                format: uuid
              matches:
                type: array
                items:
                  type: object
                  required:
                    - seq
                    - channel
                    - start_ms
                    - end_ms
                    - fragment
                  properties:
                    seq:
                      type: integer
                      minimum: 0
                    channel:
                      type: integer
                      minimum: 0
                      description: "Audio channel: 0 or 1."
                    start_ms:
                      type: integer
                      minimum: 0
                    end_ms:
                      type: integer
                      minimum: 0
                    fragment:
                      type: string
                      description: The segment text around the match (about 40 characters of context
                        on each side).
        next_cursor:
          type:
            - string
            - "null"
          description: Opaque cursor for the next page; `null` when there is nothing more
            to examine.
        capped:
          type: boolean
          description: True when the per-call cap on recordings or segments stopped the
            search before the page was full.
    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: 3
          description: >
            Retention in months (default 39). The lowest accepted is the
            tenant's

            effective floor (`GET /v1/business-types`): 39 until it declares its
            types.

            The highest accepted is `max(3, floor)` plus 12 months per block of
            extra

            retention bought (core#2423); asking for more is `422`.
        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
    BusinessTypeFloor:
      type: object
      required:
        - type
        - audio_floor_months
        - source
      properties:
        type:
          type: string
          enum:
            - cobranza
            - telemarketing
            - servicio_cliente
            - financiera_valores
            - hipotecas_servicing
        audio_floor_months:
          type: integer
          minimum: 0
          description: >
            Months the law asks to keep the AUDIO of a recorded call of this
            type

            (0 when the rule does not ask for the audio). Without the platform
            margin.
        source:
          type: string
          description: "The legal source, as ADR #246 cites it (US federal rules only)."
    BusinessTypes:
      type: object
      required:
        - declared
        - supported
        - retention_floor
      properties:
        declared:
          type:
            - array
            - "null"
          items:
            type: string
            enum:
              - cobranza
              - telemarketing
              - servicio_cliente
              - financiera_valores
              - hipotecas_servicing
          description: The types the tenant declared, sorted. `null` = it declared nothing.
        supported:
          type: array
          description: The closed vocabulary a tenant may declare, each with its audio
            floor and source.
          items:
            $ref: "#/components/schemas/BusinessTypeFloor"
        retention_floor:
          type: object
          required:
            - months
            - legal_floor_months
            - safety_margin_months
            - basis
            - imposed_by
          description: The recording-retention floor in force for NEW recordings.
          properties:
            months:
              type: integer
              minimum: 3
              description: "`legal_floor_months + safety_margin_months`. Never below the
                margin; never 0."
            legal_floor_months:
              type: integer
              minimum: 0
              description: The maximum audio floor over the declared types (over every
                supported type when nothing is declared).
            safety_margin_months:
              type: integer
              description: The platform margin added on top of the legal floor (3 today;
                inherited from planning, not a legal number).
            basis:
              type: string
              enum:
                - declared
                - undeclared_maximum
              description: "`undeclared_maximum` = the tenant declared nothing and the floor
                is the fail-closed maximum of the supported types."
            imposed_by:
              type: array
              description: The type(s) whose audio floor is the one that binds, each with its
                legal source.
              items:
                $ref: "#/components/schemas/BusinessTypeFloor"
    BusinessTypesWrite:
      type: object
      required:
        - business_types
      additionalProperties: false
      description: >
        CLOSED: an unrecognized key is `400 bad_request` naming it.
        `business_types` is

        required; `null` or `[]` withdraws the declaration.
      properties:
        business_types:
          type:
            - array
            - "null"
          items:
            type: string
            enum:
              - cobranza
              - telemarketing
              - servicio_cliente
              - financiera_valores
              - hipotecas_servicing
    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: 3
          description: Below the tenant's effective floor (39 until it declares its types;
            `GET /v1/business-types`) → `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.
    DispositionCatalogEntry:
      type: object
      required:
        - value
        - enum
        - number
        - description
      properties:
        value:
          type: string
          description: The REST spelling, for example `answered_human`.
        enum:
          type: string
          description: The `dialer.v1.Disposition` enum name, for example
            `DISPOSITION_ANSWERED_HUMAN`.
        number:
          type: integer
          minimum: 1
          description: The enum number in `dialer.v1`.
        description:
          type: string
    DispositionRule:
      type: object
      required:
        - id
        - code
        - effect
        - params
        - priority
        - active
        - updated_at
      properties:
        id:
          type: string
          format: uuid
        campaign_id:
          type:
            - string
            - "null"
          format: uuid
          description: |
            `null` for a tenant rule; the campaign's id for a rule of that
            campaign, which wins over the tenant's rules for its code in that
            campaign.
        code:
          type: string
          description: The call outcome the rule reacts to, matched verbatim (`busy`,
            `no_answer`, ...).
        effect:
          type: string
          description: >
            `reschedule` or `suppress` (the two this API writes). A rule stored

            with another effect is listed as it is and never written by this
            API.
        params:
          type: object
          additionalProperties: true
          description: |
            For `reschedule`: `delay_seconds`, `factor`, `max_delay_seconds`,
            `max_attempts` (all four stored). For `suppress`: optional `reason`.
        priority:
          type: integer
          minimum: 0
          description: Lower runs first.
        active:
          type: boolean
        updated_at:
          type: string
          format: date-time
    DispositionRulesWrite:
      type: object
      required:
        - rules
      additionalProperties: false
      properties:
        rules:
          type: array
          maxItems: 100
          items:
            type: object
            required:
              - code
              - effect
            additionalProperties: false
            properties:
              code:
                type: string
                minLength: 1
                maxLength: 64
              effect:
                type: string
                enum:
                  - reschedule
                  - suppress
              priority:
                type: integer
                minimum: 0
                maximum: 2147483647
                default: 100
              active:
                type: boolean
                default: true
              params:
                type: object
                additionalProperties: false
                description: |
                  Required for `reschedule` (`delay_seconds` and `max_attempts`
                  required inside). Optional for `suppress` (only `reason`).
                properties:
                  delay_seconds:
                    type: integer
                    minimum: 0
                    maximum: 2592000
                  factor:
                    type: number
                    minimum: 1
                    maximum: 10
                    default: 2
                  max_delay_seconds:
                    type: integer
                    minimum: 0
                    maximum: 2592000
                    default: 86400
                  max_attempts:
                    type: integer
                    minimum: 1
                    maximum: 100
                    description: |
                      How many original attempts of the debt (any outcome) this
                      policy allows before closing it instead of rescheduling.
                      Not a regulatory ceiling; the compliance gates decide
                      independently on every dial.
                  reason:
                    type: string
                    minLength: 1
                    maxLength: 64
    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.

            It does NOT say whether the text carries the `{optout_digit}`
            marker,

            which a TTS / IVR / survey campaign ALSO needs to dial (core#1305).
        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.


            Include the opt-out instruction with the `{optout_digit}` marker
            where

            the key goes (exact spelling, lower case); each campaign replaces it

            with its own key. Without the marker no TTS / IVR / survey campaign

            dials (`optout_instructions_missing`, core#1305).


            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.
    SubjectExportRequest:
      type: object
      description: Exactly one of `phone` or `consumer_ref`.
      additionalProperties: false
      properties:
        phone:
          type: string
          pattern: ^\+[1-9][0-9]{6,14}$
          description: E.164 number.
        consumer_ref:
          type: string
          minLength: 1
          maxLength: 200
        include_pii:
          type: boolean
          default: false
          description: Unredacted export; takes a dd_ key or an account_admin login,
            audited.
    SubjectExport:
      type: object
      required:
        - id
        - status
        - include_pii
        - manifest
      properties:
        id:
          type: string
          format: uuid
          description: The `requested` event of the audit trail.
        status:
          type: string
          enum:
            - done
        include_pii:
          type: boolean
        url:
          type:
            - string
            - "null"
          description: Short-lived signed URL of the ZIP.
        url_expires_at:
          type:
            - string
            - "null"
          format: date-time
        manifest:
          type: object
          description: Same content as `manifest.json` inside the ZIP.
          required:
            - subject
            - include_pii
            - categories
            - truncated
            - omitted
          properties:
            subject:
              type: object
              properties:
                kind:
                  type: string
                  enum:
                    - phone
                    - consumer_ref
                hash:
                  type: string
                  description: HMAC of the subject, hex.
            include_pii:
              type: boolean
            generated_at:
              type: string
              format: date-time
            row_cap_per_category:
              type: integer
            categories:
              type: object
              additionalProperties:
                type: object
                properties:
                  file:
                    type: string
                  tables:
                    type: array
                    items:
                      type: string
                  rows:
                    type: integer
            truncated:
              type: array
              items:
                type: string
            omitted:
              type: array
              items:
                type: object
                properties:
                  item:
                    type: string
                  reason:
                    type: string
    ErasureRequestInput:
      type: object
      description: Exactly one of `phone` or `consumer_ref`.
      additionalProperties: false
      properties:
        phone:
          type: string
          pattern: ^\+[1-9][0-9]{6,14}$
          description: E.164 number.
        consumer_ref:
          type: string
          minLength: 1
          maxLength: 200
        dry_run:
          type: boolean
          default: true
          description: "`true` (default): report only. `false`: execute (needs
            `privacy_erasure_execute_enabled`)."
    ErasureRequest:
      type: object
      required:
        - id
        - dry_run
        - report
      properties:
        id:
          type: string
          format: uuid
          description: The `privacy_requests` row.
        dry_run:
          type: boolean
        ledger_copy:
          type: string
          enum:
            - shipped
            - not_configured
            - failed
          description: "Execution only: outcome of the copy of the erasure record outside
            the database. `not_configured` or `failed` means this erasure would
            be lost on a restore; the operator must fix the sink and re-copy."
        report:
          type: object
          required:
            - subject
            - dry_run
            - records_floor_months
            - categories
            - derived_copies
            - execution
          properties:
            subject:
              type: object
              properties:
                kind:
                  type: string
                  enum:
                    - phone
                    - consumer_ref
                hash:
                  type: string
                  description: HMAC of the subject, hex.
            dry_run:
              type: boolean
            generated_at:
              type: string
              format: date-time
            records_floor_months:
              type: integer
              description: The tenant's records floor, never below the 3-month safety margin.
            cutoff:
              type: string
              format: date-time
              description: Records dated before this are anonymized.
            chain_seq:
              type:
                - integer
                - "null"
              description: "Execution only: position of the tombstone in the compliance audit
                chain."
            floor_basis:
              type: string
              enum:
                - declared
                - default_max_of_supported_types
            categories:
              type: array
              items:
                type: object
                properties:
                  category:
                    type: string
                  tables:
                    type: array
                    items:
                      type: string
                  found:
                    type: integer
                  would_anonymize:
                    type: integer
                  anonymized:
                    type:
                      - integer
                      - "null"
                    description: "Execution only: rows anonymized (audio purged for recordings).
                      null = not attempted (AI transcripts with their purge flag
                      off)."
                  action:
                    type: string
                    enum:
                      - anonymize
                      - none
                  events:
                    type: integer
                    description: "AI conversations only: their events."
                  retained:
                    type: array
                    items:
                      type: object
                      properties:
                        count:
                          type: integer
                        reason:
                          type: string
                        until:
                          type:
                            - string
                            - "null"
                          description: null = until released or unsealed.
            derived_copies:
              type: array
              items:
                type: object
                properties:
                  item:
                    type: string
                  status:
                    type: string
                  until:
                    type:
                      - string
                      - "null"
                  reason:
                    type: string
            execution:
              type: string
              enum:
                - simulated
                - executed
    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.
        include_pii:
          type: boolean
          default: false
          description: >
            `true` produces the archive WITHOUT PII redaction (ADR #240).
            Requires

            a `dd_` key or an `account_admin` login: `403 role_forbidden`
            otherwise.

            Absent or `false` ⇒ redacted. Audited.
    RecordingExportJob:
      type: object
      required:
        - job_id
        - status
      properties:
        job_id:
          type: string
          format: uuid
        status:
          type: string
          enum:
            - queued
            - running
            - done
            - failed
            - expired
          description: |
            `expired` is reported on read for a `done` job whose archive is no
            longer in the export store; regenerate with a new export request.
        pii:
          type: string
          enum:
            - redacted
            - raw
          description: |
            Whether the archive was produced with the PII of the closed ADR #240
            catalog redacted (default) or raw (`include_pii: true`).
        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
    UnitRateEntry:
      type: object
      description: One price in force (core#2834).
      required:
        - key
        - price
        - billing_increment_secs
        - effective_from
      properties:
        key:
          type: string
          description: E.164 prefix (voice), region, number type or `default`.
        price:
          type: string
          description: Decimal string, USD per unit.
          example: "0.010000"
        billing_increment_secs:
          type:
            - integer
            - "null"
          description: Voice rows only.
        effective_from:
          type: string
          format: date
    ScreenRecordingChunkAck:
      type: object
      required:
        - chunk
      properties:
        chunk:
          type: object
          required:
            - recording_id
            - seq
            - bytes
            - status
            - retained_until
          properties:
            recording_id:
              type: string
              format: uuid
            seq:
              type: integer
              minimum: 0
            bytes:
              type: integer
              description: Size of the chunk before sealing.
            status:
              type: string
              enum:
                - stored
                - duplicate
            retained_until:
              type: string
              format: date
              description: Inherited from the voice recording of the same call.
    Subaccount:
      type: object
      description: >
        A sede opened by the caller (core#2851). `limits` are its OWN ceilings
        (null = its trust

        level's); `effective_limits` is the lower of those and the parent's, per
        field.
      required:
        - id
        - name
        - status
        - parent_tenant_id
        - account_id
        - billing_account_id
        - limits
        - effective_limits
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
        site_label:
          type: string
        status:
          type: string
          enum:
            - active
            - suspended
            - closing
            - closed
        parent_tenant_id:
          type: string
          format: uuid
        account_id:
          type: string
          format: uuid
        billing_account_id:
          type: string
          format: uuid
          description: The parent's billing account.
        limits:
          $ref: "#/components/schemas/SubaccountLimits"
        effective_limits:
          $ref: "#/components/schemas/SubaccountLimits"
        created_at:
          type: string
          format: date-time
    SubaccountLimits:
      type: object
      properties:
        max_cps:
          type:
            - integer
            - "null"
        max_concurrent_channels:
          type:
            - integer
            - "null"
        daily_spend_cap_usd:
          type:
            - string
            - "null"
          description: Decimal USD.
    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.
    RecordingExportTooLarge:
      type: object
      description: "`POST /v1/recording-exports` refused because the filters match
        more recordings than the row limit: the `ErrorEnvelope` plus `max_rows`
        and `matching_rows`."
      required:
        - error
      properties:
        error:
          type: object
          required:
            - code
            - message
            - max_rows
            - matching_rows
          properties:
            code:
              type: string
              const: export_too_large
            message:
              type: string
              description: Human-readable; never branch on it.
            max_rows:
              type: integer
              description: The platform row limit (`recording_export_max_rows`).
            matching_rows:
              type: integer
              description: How many recordings the filters match.
    AgentStatusConflict:
      type: object
      description: "`PATCH /v1/agents/{id}/status` refused by the seat FSM: the
        `ErrorEnvelope` plus the seat's `current_state`."
      required:
        - error
      properties:
        error:
          type: object
          required:
            - code
            - message
            - current_state
          properties:
            code:
              type: string
              const: conflict
            message:
              type: string
              description: Human-readable; never branch on it.
            current_state:
              type: string
              enum:
                - offline
                - available
                - ringing
                - on_call
                - wrap_up
                - paused
              description: The seat's state when the FSM refused (`offline` = not checked in).
    AgentMonitoringNotice:
      type: object
      description: The CURRENT version of the recording-and-supervision notice, one
        text per language (`GET /v1/agent-monitoring-notice`). Global,
        append-only.
      required:
        - version
        - effective_at
        - texts
      properties:
        version:
          type: integer
          minimum: 1
        effective_at:
          type: string
          format: date-time
        texts:
          type: array
          minItems: 1
          items:
            type: object
            required:
              - lang
              - title
              - body
              - text_sha256
              - published_at
            properties:
              lang:
                type: string
                description: Language of the text, e.g. `es`.
              title:
                type: string
              body:
                type: string
              text_sha256:
                type: string
                pattern: ^[0-9a-f]{64}$
                description: SHA-256 (hex) of `body` in UTF-8; an acceptance copies it.
              published_at:
                type: string
                format: date-time
    AgentMonitoringAck:
      type: object
      description: One acceptance of one notice version by one agent. Append-only.
      required:
        - id
        - agent_id
        - notice_version
        - lang
        - text_sha256
        - channel
        - acked_at
      properties:
        id:
          type: string
          format: uuid
        agent_id:
          type: string
          format: uuid
        notice_version:
          type: integer
          minimum: 1
        lang:
          type: string
          description: The language of the text the agent saw.
        text_sha256:
          type: string
          pattern: ^[0-9a-f]{64}$
          description: The hash of the text the agent saw (copied from the notice version).
        channel:
          type: string
          enum:
            - dashboard_checkin
          description: The origin of the acceptance.
        acked_at:
          type: string
          format: date-time
    AgentMonitoringStatus:
      type: object
      description: An agent's standing against the current notice.
      required:
        - notice_version
        - current
        - acknowledgement
      properties:
        notice_version:
          oneOf:
            - type: integer
              minimum: 1
            - type: "null"
          description: The current version; `null` while none is in force.
        current:
          type: boolean
          description: True only when the agent has accepted the CURRENT version.
        acknowledgement:
          oneOf:
            - $ref: "#/components/schemas/AgentMonitoringAck"
            - type: "null"
          description: The agent's latest acceptance of any version; `null` if none.
    ScreenMonitorNotice:
      type: object
      description: One version of the screen-monitoring notice (`GET
        /v1/screen-monitor/notice`). Global, append-only.
      required:
        - version
        - lang
        - title
        - body
        - text_sha256
        - effective_at
        - published_at
      properties:
        version:
          type: integer
          minimum: 1
        lang:
          type: string
          description: Language of the text, e.g. `es`.
        title:
          type: string
        body:
          type: string
        text_sha256:
          type: string
          pattern: ^[0-9a-f]{64}$
          description: SHA-256 (hex) of `body` in UTF-8; an acknowledgement copies it.
        effective_at:
          type: string
          format: date-time
        published_at:
          type: string
          format: date-time
    ScreenMonitorAck:
      type: object
      description: One acknowledgement of one notice version by one seat. Append-only.
      required:
        - id
        - agent_id
        - notice_version
        - scope
        - text_sha256
        - channel
        - acked_at
      properties:
        id:
          type: string
          format: uuid
        agent_id:
          type: string
          format: uuid
        notice_version:
          type: integer
          minimum: 1
        scope:
          type: string
          enum:
            - live_monitoring
            - screen_recording
          description: The scope accepted; one row per (seat, notice version, scope).
        text_sha256:
          type: string
          pattern: ^[0-9a-f]{64}$
          description: The hash of the text the seat saw (copied from the notice version).
        channel:
          type: string
          enum:
            - dashboard_checkin
        acked_at:
          type: string
          format: date-time
    ScreenMonitorStatus:
      type: object
      additionalProperties: false
      required:
        - enabled
        - notice_ack_required
        - notice_version
        - scopes
      description: "What `GET /v1/me` reports about screen monitoring for the caller's
        seat (core#1989). Fail-closed: a scope is authorized only when `enabled`
        is `true` AND `scopes.<scope>.ack_current` is `true`. `enabled: false`
        (the flag is off, or could not be resolved) reads every other field as
        empty. `notice_ack_required` is `true` whenever the function is enabled
        and the seat lacks an acknowledgement of the CURRENT version for ANY
        scope, also while no notice is published yet (then there is nothing to
        acknowledge: still closed). A caller with no seat (`dd_` key, `ddu_`
        without a seat, `dds_`) sees `notice_ack_required: false` and every
        `ack_current: false`."
      properties:
        enabled:
          type: boolean
        notice_ack_required:
          type: boolean
        notice_version:
          oneOf:
            - type: integer
              minimum: 1
            - type: "null"
          description: The current notice version, or `null` when none is published or the
            function is off.
        scopes:
          type: object
          additionalProperties: false
          required:
            - live_monitoring
            - screen_recording
          description: Per scope, whether the seat has an acknowledgement of the CURRENT
            version. An acknowledgement of one scope never makes the other
            current.
          properties:
            live_monitoring:
              $ref: "#/components/schemas/ScreenMonitorScopeStatus"
            screen_recording:
              $ref: "#/components/schemas/ScreenMonitorScopeStatus"
    ScreenMonitorScopeStatus:
      type: object
      additionalProperties: false
      required:
        - ack_current
        - acked_at
      properties:
        ack_current:
          type: boolean
        acked_at:
          oneOf:
            - type: string
              format: date-time
            - type: "null"
          description: When the seat acknowledged the current version for this scope, or
            `null`.
    ScreenMonitorSession:
      type: object
      description: One row of the screen-monitor session ledger (core#1990).
        Append-only; it never carries signalling payload.
      required:
        - id
        - supervisor_user_id
        - supervisor_id
        - agent_id
        - notice_version
        - result
        - reason
        - started_at
        - ended_at
      properties:
        id:
          type: string
          format: uuid
        supervisor_user_id:
          type: string
          format: uuid
          description: The login that asked.
        supervisor_id:
          oneOf:
            - type: string
              format: uuid
            - type: "null"
          description: The asker's seat; `null` when the login has no seat.
        agent_id:
          oneOf:
            - type: string
              format: uuid
            - type: "null"
          description: The watched seat; `null` when it did not resolve for the tenant or
            the role was refused first.
        notice_version:
          oneOf:
            - type: integer
              minimum: 1
            - type: "null"
          description: The notice version in force when the session opened, when the agent
            had one.
        result:
          type: string
          enum:
            - started
            - ended
            - failed
            - refused
        reason:
          oneOf:
            - type: string
              enum:
                - flag_off
                - role
                - agent_not_found
                - no_ack
                - not_armed
                - agent_busy
                - agent_stopped
                - credential_lapsed
                - checkout
                - peer_failed
            - type: "null"
          description: Why a session was refused, failed or ended by the system; `null`
            for a normal close or while `started`.
        started_at:
          type: string
          format: date-time
        ended_at:
          oneOf:
            - type: string
              format: date-time
            - type: "null"
    AgentQueue:
      type: object
      description: What the dialer has in front of one seat (`GET /v1/agent/queue`).
        Ids, timestamps and priority only — no PII.
      required:
        - agent_id
        - presence
        - live_call
        - callbacks
        - inbound_offers
      properties:
        agent_id:
          type: string
          format: uuid
        presence:
          type: string
          enum:
            - offline
            - available
            - ringing
            - on_call
            - wrap_up
            - paused
        live_call:
          type:
            - object
            - "null"
          required:
            - attempt_id
            - campaign_id
            - debt_id
            - started_at
            - answered_at
          properties:
            attempt_id:
              type: string
              format: uuid
            campaign_id:
              type:
                - string
                - "null"
              format: uuid
            debt_id:
              type:
                - string
                - "null"
              format: uuid
            started_at:
              type: string
              format: date-time
            answered_at:
              type:
                - string
                - "null"
              format: date-time
        callbacks:
          type: array
          items:
            type: object
            required:
              - id
              - debt_id
              - campaign_id
              - scheduled_at
              - priority
            properties:
              id:
                type: string
                format: uuid
              debt_id:
                type:
                  - string
                  - "null"
                format: uuid
              campaign_id:
                type:
                  - string
                  - "null"
                format: uuid
              scheduled_at:
                type: string
                format: date-time
              priority:
                type: integer
        inbound_offers:
          type: array
          items:
            type: object
            required:
              - queue_id
              - admission_id
              - call_id
              - enqueued_at
            properties:
              queue_id:
                type: string
                format: uuid
              admission_id:
                type: string
                format: uuid
              call_id:
                type: string
                format: uuid
              enqueued_at:
                type: string
                format: date-time
    SeatLimitReachedError:
      type: object
      description: "`POST /v1/agents` refused because the tenant's tier is at its seat cap."
      required:
        - error
      properties:
        error:
          type: object
          required:
            - code
            - message
            - max_seats
            - tier
            - active_seats
          properties:
            code:
              type: string
              const: seat_limit_reached
            message:
              type: string
              description: Human-readable; never branch on it.
            max_seats:
              type: integer
              minimum: 1
              description: The cap of the tier in force, from the tier catalog.
            tier:
              type: string
              description: The tier in force whose cap was reached.
            active_seats:
              type: integer
              minimum: 0
              description: Active seats the tenant already holds. Can be above `max_seats`
                after a move to a smaller tier.
    TooManyConnectionsError:
      type: object
      description: "`POST /v1/ws/ticket` refused because a live-floor socket cap is
        full (core#1285)."
      required:
        - error
      properties:
        error:
          type: object
          required:
            - code
            - message
            - scope
          properties:
            code:
              type: string
              const: too_many_connections
            message:
              type: string
              description: Human-readable; never branch on it.
            scope:
              type: string
              enum:
                - tenant
                - widget_token
              description: "Which cap is full: the tenant's, or this `ddw_` widget token's."
    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.
    PreDialComplianceBlockedError:
      type: object
      required:
        - error
      description: |
        A GATE_PRE_DIAL refusal of the NEW LEG an external blind transfer would
        create. Unlike the mid-call envelope this one carries a real engine
        `decision_id`: the chain ran, the refusal was written to `gate_blocks`
        and its decision sealed into the audit chain, so the id RESOLVES through
        `GET /v1/compliance/decisions/{decision_id}`. It never carries
        `auto_hangup_at`: nothing was dialled, so there is no deadline to arm.
      properties:
        error:
          type: object
          required:
            - code
            - message
            - gate
            - reason_code
          properties:
            code:
              type: string
              const: compliance_blocked
            message:
              type: string
            gate:
              type: string
              const: pre_dial
              description: The checkpoint (GATE_PRE_DIAL), not the refusing rule — that is
                `reason_code`.
            reason_code:
              type: string
              description: |
                The refusing gate's reason code: the `ComplianceGate`
                vocabulary, plus `engine_unavailable` when the chain could not
                be evaluated at all (fail-closed — a gate that cannot decide
                blocks, and that refusal has no engine decision behind it),
                `destination_unregistered` when the destination is not in the
                tenant's `/v1/transfer-destinations` registry,
                `destination_registry_unavailable` when that registry could not
                be read (both refused before the chain runs),
                `destination_not_allowed` when the destination is outside the
                allowlist of a BYOC trunk of the sede,
                `destination_allowlist_unavailable` when that allowlist could
                not be read, `destination_class_blocked` when the destination's
                class is not unlocked for the tenant, and
                `destination_policy_unavailable` when that policy could not be
                read (all four refused before the registry).
              example: dnc_listed
            rule_id:
              type: string
              description: The canonical rule the refusing gate cites, e.g. `dnc.lists.v1`.
            decision_id:
              type: string
              description: |
                The immutable `ComplianceDecision` this refusal came from,
                resolvable through `GET /v1/compliance/decisions/{decision_id}`.
                ABSENT when no new evidence row was written: for
                `engine_unavailable`, `destination_unregistered` and
                `destination_registry_unavailable`, where no decision was
                produced, and for a
                same-day duplicate of an already evidenced refusal, where the
                evidence table deliberately writes no second row or audit link.
    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
        - artificial_voice_no_consent
    Tenant:
      type: object
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
        account_id:
          type: string
          format: uuid
          description: The EMPRESA this tenant (sede) belongs to (DA-2).
        billing_account_id:
          type: string
          format: uuid
          description: The billing account the sede is invoiced under (DA-3). Shared by
            every sede of a `consolidated` empresa; one per sede when
            `per_site`.
        status:
          type: string
          enum:
            - active
            - suspended
            - closing
            - closed
        synthetic:
          type: boolean
          description: "`true` for a demonstration tenant. It originates no traffic:
            manual dial, SMS send, the external leg of a transfer and the
            sandbox answer `403 synthetic_tenant`. Always present, never null,
            so a console can warn BEFORE the action fails (core#2512)."
        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 ceiling of concurrent calls, one unit per call with or
            without an agent (admin-set; null = no ceiling). ADR #12, ADR #185."
        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."
        kyc_verified_at:
          oneOf:
            - type: string
              format: date-time
            - type: "null"
          description: "When the platform verified this customer (admin-set; `null` = not
            yet). A 1-seat account that is not verified cannot create or start a
            campaign that runs without an agent (`403` `verification_required`,
            ADR #279)."
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    ApiKeyPermission:
      type: string
      enum:
        - billing
        - keys
        - telephony
        - messaging
        - recordings
        - webhooks
        - general
      description: "Route group (core#2850), by the first path segment under `/v1`:
        `billing` (also `skip-trace`, `/v1/stats/ai-usage` and
        `/v1/stats/sms-usage`); `keys` (api_keys, widget_tokens, widget_origins,
        users, sso, support-access); `telephony` (dial, calls, campaigns,
        agents, dids, carriers, inbound-*, transfer*, click-to-call, callbacks,
        supervision, and `POST /v1/ws/ticket`, which opens the live call-event
        socket); `messaging` (sms, email and their templates); `recordings`
        (recordings, recording-*, screen-*, transcripts, qm); `webhooks`;
        `general` (every other route, and any route added later)."
    ApiRequest:
      type: object
      description: One request made with an API key. No body, header, query string or
        IP is kept.
      required:
        - id
        - request_id
        - method
        - route
        - status
        - duration_us
        - requested_at
      properties:
        id:
          type: string
          format: uuid
        request_id:
          type: string
          description: The `x-request-id` of the request.
        method:
          type: string
        route:
          type: string
          description: Route template, never the URL with ids.
        status:
          type: integer
        duration_us:
          type: integer
          description: Microseconds from the key being accepted to the response leaving.
        requested_at:
          type: string
          format: date-time
    ApiRequestMinute:
      type: object
      required:
        - minute
        - requests
        - rate_limited
        - server_errors
      properties:
        minute:
          type: string
          format: date-time
        requests:
          type: integer
        rate_limited:
          type: integer
          description: Responses with status 429.
        server_errors:
          type: integer
          description: Responses with status >= 500.
    ApiKey:
      type: object
      description: Key metadata — the token hash is never exposed.
      properties:
        id:
          type: string
          format: uuid
        label:
          type: string
        environment:
          type: string
          enum:
            - live
            - test
          description: "`live` (every key that predates the field) or `test` (core#2837):
            a test key reaches no outbound path. Fixed at creation."
        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."
        allowed_cidrs:
          type: array
          items:
            type: string
          description: Network allowlist (core#2450). `[]` = unrestricted. Outside it a
            request is served and counted, or refused with `401`, per the tenant
            knob `api_key_network_enforced` (default off).
        stale:
          type: boolean
          description: "`true` for a live key unused for 90 days (a key that never
            authenticated counts from its creation). Advice for the console to
            rotate or revoke; a key expires on its own only when it has an
            `expires_at`."
        permissions:
          oneOf:
            - type: array
              items:
                $ref: "#/components/schemas/ApiKeyPermission"
            - type: "null"
          description: Route groups the key may enter (core#2850). `null` = all (every key
            that predates the field).
        expires_at:
          oneOf:
            - type: string
              format: date-time
            - type: "null"
          description: Instant from which the key stops authenticating (core#2850); `null`
            = never. A rotation sets it on the old key to the end of the
            overlap.
        created_by_user_id:
          oneOf:
            - type: string
              format: uuid
            - type: "null"
          description: The dashboard user whose session minted the key (core#2359). `null`
            when another `dd_` key, the operator or the seeds did, and on every
            key that predates the field.
        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"
        mfa_enabled:
          type: boolean
          description: "Whether the person has an ACTIVE second factor (an enrolment still
            pending confirmation is `false`). Emitted ONLY when the caller is an
            `account_admin` dashboard login — the one who can act on it with
            `POST /v1/users/{id}/mfa/remove`; for a `member`, a `dd_` key, or
            when the caller's role cannot be determined the field is ABSENT (not
            `false`). Wherever a `User` is rendered, the same rule holds: `GET
            /v1/users`, `GET /v1/me`, the user returned by `DELETE
            /v1/users/{id}` and `POST /v1/users/{id}/enable`, and the user in
            the session responses (`POST /v1/auth/login`, `POST
            /v1/auth/sso/exchange`, and the two MFA completion routes) — there,
            for the `account_admin` who just signed in."
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    UserInvitation:
      type: object
      description: |
        An open invitation to the dashboard (ADR #182) — the token hash is never
        exposed. Listed identically whether the address was free or already a
        user of another tenant.
      properties:
        id:
          type: string
          format: uuid
        email:
          type: string
          format: email
          description: Stored lowercase.
        name:
          type: string
        status:
          type: string
          enum:
            - pending
            - expired
          description: "`pending` until `expires_at`, `expired` after. Resend by inviting
            again."
        expires_at:
          type: string
          format: date-time
        created_at:
          type: string
          format: date-time
        invited_by:
          type: object
          description: >
            Who made the invitation (core#2449), immutable. `kind` is `user` (a
            dashboard login;

            `user_id` is that person), `api_key` (a `dd_` key; `user_id` null)
            or `operator`.

            `kind` is null for an invitation that predates the record.
          required:
            - kind
            - user_id
          properties:
            kind:
              type:
                - string
                - "null"
              enum:
                - user
                - api_key
                - operator
                - null
            user_id:
              type:
                - string
                - "null"
              format: uuid
    MfaChallenge:
      type: object
      description: >
        The first step of a login whose account has a second factor: NO session
        yet.

        The `challenge` is single-use, lives 5 minutes and opens no route other
        than

        `POST /v1/auth/mfa/verify`.
      required:
        - mfa_required
        - challenge
        - expires_at
        - methods
      properties:
        mfa_required:
          type: boolean
          enum:
            - true
        challenge:
          type: string
          description: "`ddm_` plaintext, shown exactly once; only its sha256 is stored."
        expires_at:
          type: string
          format: date-time
        methods:
          type: array
          items:
            type: string
            enum:
              - totp
              - recovery_code
    MfaEnrollmentChallenge:
      type: object
      description: >
        The first step of a login in a tenant that REQUIRES a second factor
        (flag

        `mfa_required_enforced`) for an account that has none: NO session yet.
        The

        `challenge` is single-use, lives 5 minutes and opens no route other than

        `POST /v1/auth/mfa/enroll` and `POST /v1/auth/mfa/enroll/confirm`; it is

        not accepted at `POST /v1/auth/mfa/verify`. There are no `methods`:
        there

        is nothing to verify yet.
      required:
        - mfa_required
        - purpose
        - challenge
        - expires_at
      properties:
        mfa_required:
          type: boolean
          enum:
            - true
        purpose:
          type: string
          enum:
            - enroll
        challenge:
          type: string
          description: "`ddm_` plaintext, shown exactly once; only its sha256 is stored."
        expires_at:
          type: string
          format: date-time
    MfaStatus:
      type: object
      required:
        - enabled
        - recovery_codes_remaining
      properties:
        enabled:
          type: boolean
        recovery_codes_remaining:
          type: integer
          minimum: 0
    MfaEnrollment:
      type: object
      required:
        - secret
        - otpauth_uri
      properties:
        secret:
          type: string
          description: TOTP secret, base32; shown exactly once.
        otpauth_uri:
          type: string
          description: "`otpauth://totp/...` — encode it as the QR."
    RecoveryCodes:
      type: object
      required:
        - recovery_codes
      properties:
        recovery_codes:
          type: array
          description: Single-use codes, shown in clear exactly once.
          items:
            type: string
            example: ABCD-EFGH-IJKL-MNOP
    MfaReauth:
      type: object
      required:
        - password
      description: The current password plus EXACTLY ONE of `code` and `recovery_code`.
      properties:
        password:
          type: string
          format: password
        code:
          type: string
          example: "492039"
        recovery_code:
          type: string
      oneOf:
        - required:
            - code
        - required:
            - recovery_code
    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
    WidgetSipCredential:
      type: object
      description: >
        What the frame needs to REGISTER over WSS with the ephemeral credential
        of

        ONE widget session, and nothing more. Returned once, in memory, by

        `POST /v1/widget_tokens/sip_credential`.
      required:
        - username
        - password
        - domain
        - aor
        - wss_url
        - expires_at
      properties:
        username:
          type: string
          description: >
            The digest username of THIS session, `w-<token_id>` — derived from
            the

            `widget_tokens` row, NEVER from the agent's `sip_extension`. That

            derivation is the whole point of the lane: the frame's identity dies

            with the session instead of outliving it on the agent's extension.
          example: w-3f2b7c1e-11aa-4bd0-9c7e-0a6d5e4b3c21
        password:
          type: string
          description: |
            The ONLY secret here, returned EXACTLY ONCE. It is not stored on the
            server side in any readable form — it lives only in the Kamailio
            `agentcred_w` htable (ADR #7). Keep it in memory; web storage would
            hand it to any XSS in the embedding page, which is the threat this
            lane exists to bound.
        domain:
          type: string
          description: |
            The tenant-qualified SIP domain `t-<tenant>.<base>` — the From/AOR
            domain and the digest realm. The realm is what binds tenancy, so
            isolation is derived from this, never from a global extension.
          example: t-9d1f0e2a-6c33-4a10-b0d9-7e5c2f814b6a.sip.example
        aor:
          type: string
          description: |
            The fully-qualified SIP identity of this session,
            `w-<token_id>@t-<tenant>.<base>` — both the `agentcred_w` htable key
            and the usrloc AOR the frame's registration binds. Sent explicitly
            even though it is `username` + `domain`: it is the identity inbound
            calls arrive at, and making the client recompose it is asking it to
            reimplement the server's identity rules.
          example: w-3f2b7c1e-11aa-4bd0-9c7e-0a6d5e4b3c21@t-9d1f0e2a-6c33-4a10-b0d9-7e5c2f814b6a.sip.example
        wss_url:
          type:
            - string
            - "null"
          description: >
            The Kamailio WSS edge to register against. ONE edge serves both
            lanes

            — what separates the widget from the softphone is the htable and the

            AOR, not the address. `null` in an environment that configures no

            softphone bootstrap.
          example: wss://sip.example:8443
        expires_at:
          type: string
          format: date-time
          description: >
            When the SERVER itself will dismantle this credential: the EARLIER
            of

            the `agentcred_w` autoexpire and the session's absolute cap

            (`session_expires_at`), which the sweeper enforces with an eviction.


            Deliberately NOT the token's sliding `expires_at`, even though that
            is

            the shortest of the three: nothing turns that clock into an htable

            eviction, so announcing it would send the frame to re-mint a

            credential that is still registering fine — and every re-mint
            retires

            the previous password of that same session, causing the very drop
            the

            warning was meant to avoid. Renew the TOKEN on that clock; re-mint
            the

            CREDENTIAL on this one.
    WidgetSipTeardown:
      type: string
      enum:
        - ok
        - skipped
        - failed
      description: >
        Outcome of tearing down a WIDGET session's ephemeral credential — the

        `agentcred_w` lane. Distinct from `SipTeardown`, which reports on the

        agent's `agentcred`: the two lanes have separate htables and separate

        AORs precisely so that one can never reach the other, and reporting them

        with one schema is how they start being read as one.


        * `ok` — credential and usrloc binding removed; this frame can no longer
          register.
        * `skipped` — nothing to remove: the widget SIP lane is not armed in
        this
          environment, so no credential was ever issued.
        * `failed` — the SBC RPC failed and the credential stays live until the
          `agentcred_w` autoexpire. Logged and emitted as telemetry rather than
          swallowed. Reported inside a 200 on purpose; do not retry in a loop.
    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"
          description: "Rotation-hygiene clock, same meaning as `ApiKey.last_used_at`: the
            last time this token resolved SUCCESSFULLY (every gate cleared),
            written at most once a minute per token, so a fresh reading may lag
            by up to that much. `null` means it has not resolved successfully
            since the column acquired a hot-path writer — not that the token was
            never presented. Renewal also stamps it."
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
          description: "Dates the ROW, not the mint: the use clock above and the lifecycle
            writes (renew, teardown) all move it."
    ClickToCallSiteInput:
      type: object
      additionalProperties: false
      required:
        - campaign_id
      properties:
        campaign_id:
          type: string
          format: uuid
        label:
          type: string
        allow_immediate:
          type: boolean
          default: true
        allow_scheduled:
          type: boolean
          default: true
        tenant_daily_cap:
          type: integer
        number_daily_cap:
          type: integer
        site_hourly_cap:
          type: integer
        consent_text:
          type: string
          minLength: 1
          maxLength: 4000
          description: Becomes consent version 1 of the site.
    ClickToCallSiteUpdate:
      type: object
      additionalProperties: false
      description: Partial update; every field is optional and none of them accepts null.
      properties:
        campaign_id:
          type: string
          format: uuid
        label:
          type: string
          maxLength: 120
        allow_immediate:
          type: boolean
        allow_scheduled:
          type: boolean
        tenant_daily_cap:
          type: integer
          minimum: 1
          maximum: 10000
        number_daily_cap:
          type: integer
          minimum: 1
          maximum: 20
        site_hourly_cap:
          type: integer
          minimum: 1
          maximum: 1000
        consent_text:
          type: string
          minLength: 1
          maxLength: 4000
          description: A text different from the current one creates version N+1.
    ClickToCallSite:
      type: object
      description: A click-to-call site as the console sees it. Never carries the key.
      properties:
        id:
          type: string
          format: uuid
        campaign_id:
          type: string
          format: uuid
        label:
          type: string
        allow_immediate:
          type: boolean
        allow_scheduled:
          type: boolean
        tenant_daily_cap:
          type: integer
        number_daily_cap:
          type: integer
        site_hourly_cap:
          type: integer
        revoked_at:
          oneOf:
            - type: string
              format: date-time
            - type: "null"
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        consent_version:
          oneOf:
            - type: string
            - type: "null"
          description: Current consent text version (decimal string, e.g. "2"); null while
            the site has no text.
        consent_text:
          oneOf:
            - type: string
              maxLength: 4000
            - type: "null"
        consent_sha256:
          oneOf:
            - type: string
              pattern: ^[0-9a-f]{64}$
            - type: "null"
          description: Lowercase hex sha256 of the bytes of `consent_text`, computed by
            the server.
    ClickToCallPublicConfig:
      type: object
      properties:
        site_id:
          type: string
          format: uuid
        label:
          type: string
        allow_immediate:
          type: boolean
        allow_scheduled:
          type: boolean
        consent_version:
          oneOf:
            - type: string
            - type: "null"
          description: Current consent text version (decimal string, e.g. "2"); null while
            the site has no text.
        consent_text:
          oneOf:
            - type: string
              maxLength: 4000
            - type: "null"
        consent_sha256:
          oneOf:
            - type: string
              pattern: ^[0-9a-f]{64}$
            - type: "null"
          description: Lowercase hex sha256 of the bytes of `consent_text`, computed by
            the server.
    CallRequestInput:
      type: object
      additionalProperties: false
      required:
        - mode
        - phone
        - consent_text_version
        - consent_text_sha256
        - consent_checked
      properties:
        mode:
          type: string
          enum:
            - immediate
            - scheduled
        scheduled_for:
          type: string
          format: date-time
          description: Required when `mode` is `scheduled`, forbidden when `immediate`.
        phone:
          type: string
          description: Normalized E.164, e.g. +13125550100.
        consent_text_version:
          type: string
          maxLength: 64
          description: The `consent_version` the form got from the public config.
        consent_text_sha256:
          type: string
          description: The `consent_sha256` the form got from the public config.
        consent_checked:
          type: boolean
          description: "The visitor's declaration. Must be `true`: `false` answers `422
            consent_not_checked` and nothing is written."
        captcha_token:
          type: string
          description: Optional. Checked by the captcha provider when the cell configures
            one (none by default); a rejected token answers `403
            captcha_failed`.
    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
    ExperimentErrorEnvelope:
      type: object
      description: "The `ErrorEnvelope` of a refused experiment, plus `details`: one
        entry per refused field."
      required:
        - error
      properties:
        error:
          type: object
          required:
            - code
            - message
            - details
          properties:
            code:
              type: string
              const: unprocessable
            message:
              type: string
              description: Human-readable; never branch on it.
            details:
              type: array
              items:
                type: object
                required:
                  - path
                  - message
                properties:
                  path:
                    type: string
                    description: Where the refusal is, e.g. `variants[1].overrides.window`.
                  message:
                    type: string
    AiAgentExperimentVariantOverrides:
      type: object
      description: >
        What a variant changes about its AI agent: a CLOSED list. Any other key
        is refused

        with `422` (never ignored). Nothing legal is in the list: not the
        disclosure, the

        spoken opt-out, the handoff rules, the tools, the negotiation limits nor
        the

        prohibited phrases.
      additionalProperties: false
      properties:
        voice:
          type: object
          description: At least one of the two. `voice_id` must be in the voice catalog
            (`GET /v1/ai/voices`); the disclosure is pre-rendered with the voice
            of EACH variant before dialing.
          properties:
            voice_id:
              type: string
            pace:
              type: number
              minimum: 0
              maximum: 1
        prompt_style:
          type: string
          minLength: 1
          maxLength: 600
          description: One line (no line break), appended after everything the agent
            composes; it cannot replace any of it.
        model:
          type: string
          description: A model of the platform's model catalog.
          enum:
            - openai/gpt-oss-20b
            - openai/gpt-oss-120b
            - llama-3.3-70b-versatile
            - llama-3.1-8b-instant
        llm:
          type: object
          description: At least one of the two.
          properties:
            reasoning_effort:
              type: string
              enum:
                - low
                - medium
                - high
            prompt_form:
              type: string
              enum:
                - compact
                - full
    AiAgentExperimentVariantWrite:
      type: object
      required:
        - name
        - weight
      properties:
        name:
          type: string
          maxLength: 200
        weight:
          type: integer
          minimum: 1
          maximum: 1000
          description: Share of the traffic, relative to the sum of the weights.
        is_control:
          type: boolean
          default: false
          description: Exactly one variant of the experiment is the control (the base
            agent, so it sends no overrides).
        overrides:
          $ref: "#/components/schemas/AiAgentExperimentVariantOverrides"
    AiAgentExperimentWrite:
      type: object
      description: "`POST` needs `name`, `primary_metric` and `variants`; `PATCH`
        takes `name` and/or `variants`."
      properties:
        name:
          type: string
          maxLength: 200
        primary_metric:
          type: string
          enum:
            - ptp_rate
            - contained_rate
            - handoff_rate
            - first_audio_p95_ms
            - cost_per_call
        variants:
          type: array
          minItems: 2
          maxItems: 5
          items:
            $ref: "#/components/schemas/AiAgentExperimentVariantWrite"
    AiAgentExperimentVariant:
      type: object
      properties:
        id:
          type: string
          format: uuid
        position:
          type: integer
          description: Order the variants are walked in to assign a debt (0-based).
        name:
          type: string
        is_control:
          type: boolean
        weight:
          type: integer
        overrides:
          $ref: "#/components/schemas/AiAgentExperimentVariantOverrides"
    AiAgentExperiment:
      type: object
      properties:
        id:
          type: string
          format: uuid
        ai_agent_id:
          type: string
          format: uuid
        name:
          type: string
        state:
          type: string
          enum:
            - draft
            - running
            - ended
        primary_metric:
          type: string
          enum:
            - ptp_rate
            - contained_rate
            - handoff_rate
            - first_audio_p95_ms
            - cost_per_call
        started_at:
          type:
            - string
            - "null"
          format: date-time
        ended_at:
          type:
            - string
            - "null"
          format: date-time
        ended_reason:
          type:
            - string
            - "null"
          description: Why it ended, when something other than a manual stop ended it;
            null for a manual stop. Free text in English for people; read
            `ended_cause` to branch.
        ended_cause:
          type:
            - string
            - "null"
          description: "What ended it: `manual` (someone stopped it) or `compliance_stop`
            (the platform did, on a significant violation rate). Open to new
            values; null while draft or running."
          examples:
            - compliance_stop
        promoted_at:
          type:
            - string
            - "null"
          format: date-time
          description: When the winner was applied to the agent (promote); null while it
            has not been. Read from the snapshot noted `promovido desde el
            experimento <id>`.
        promoted_policy_version:
          type:
            - integer
            - "null"
          description: The agent's `policy_version` that the promotion produced; null
            while the winner has not been applied.
        variants:
          type: array
          items:
            $ref: "#/components/schemas/AiAgentExperimentVariant"
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    AiAgentExperimentResults:
      type: object
      required:
        - experiment
        - variants
        - unattributed_conversations
        - unresolved_calls
        - verdict
        - allocation_warning
        - allocation
        - safety
      properties:
        experiment:
          type: object
          properties:
            id:
              type: string
              format: uuid
            ai_agent_id:
              type: string
              format: uuid
            name:
              type: string
            state:
              type: string
              enum:
                - draft
                - running
                - ended
            primary_metric:
              type: string
              enum:
                - ptp_rate
                - contained_rate
                - handoff_rate
                - first_audio_p95_ms
                - cost_per_call
            started_at:
              type:
                - string
                - "null"
              format: date-time
            ended_at:
              type:
                - string
                - "null"
              format: date-time
            ended_reason:
              type:
                - string
                - "null"
              description: Why it ended when something other than a manual stop ended it (the
                compliance stop writes its evidence here).
            ended_cause:
              type:
                - string
                - "null"
              description: "`manual` or `compliance_stop`; open to new values; null while
                draft or running."
              examples:
                - manual
            promoted_at:
              type:
                - string
                - "null"
              format: date-time
              description: When the winner was applied to the agent (promote); null while it
                has not been. Read from the snapshot noted `promovido desde el
                experimento <id>`.
            promoted_policy_version:
              type:
                - integer
                - "null"
              description: The agent's `policy_version` that the promotion produced; null
                while the winner has not been applied.
        variants:
          type: array
          items:
            $ref: "#/components/schemas/AiAgentExperimentVariantResults"
        unattributed_conversations:
          type: integer
          minimum: 0
          description: Conversations stamped with the experiment but with a variant it
            does not have. Non-zero means the stamp and the experiment disagree.
        unresolved_calls:
          type: object
          required:
            - total
            - by_reason
          description: Calls of this experiment that were answered by the BASE agent
            because their variant could not be resolved. They carry no variant,
            so they are in no arm and not in `unattributed_conversations`; the
            arms hold fewer units than their weights say. A read of the running
            experiment that itself failed has no experiment to attribute to and
            is not counted here.
          properties:
            total:
              type: integer
              minimum: 0
            by_reason:
              type: object
              required:
                - no_variant
                - overrides_unreadable
                - raised
                - exit
                - read_failed
              properties:
                no_variant:
                  type: integer
                  minimum: 0
                  description: The assignment found no variant.
                overrides_unreadable:
                  type: integer
                  minimum: 0
                  description: The variant has an override outside the closed list or a
                    model/voice withdrawn from the catalog.
                raised:
                  type: integer
                  minimum: 0
                  description: The resolution raised.
                exit:
                  type: integer
                  minimum: 0
                  description: The resolution exited.
                read_failed:
                  type: integer
                  minimum: 0
                  description: The read of the variant failed.
        verdict:
          $ref: "#/components/schemas/AiAgentExperimentVerdict"
        allocation_warning:
          type: boolean
          description: The observed split of assigned units deviates from the weights
            (chi-square, alpha 0.001). A `winner` is never emitted while it is
            `true`.
        allocation:
          type: object
          properties:
            chi_square:
              type: number
            degrees_of_freedom:
              type: integer
              minimum: 0
            p_value:
              type: number
            warning:
              type: boolean
        safety:
          type: object
          required:
            - breaches
          properties:
            breaches:
              type: array
              description: Variants whose compliance violation rate is significantly higher
                than the control's. Empty when there is none or when an arm has
                fewer than 100 closed conversations.
              items:
                type: object
                properties:
                  variant_id:
                    type: string
                    format: uuid
                  name:
                    type: string
                  control_rate:
                    type:
                      - number
                      - "null"
                  rate:
                    type:
                      - number
                      - "null"
                  control_closed:
                    type: integer
                  closed:
                    type: integer
                  z:
                    type:
                      - number
                      - "null"
                  p_value:
                    type: number
                  diff:
                    type: number
    AiAgentExperimentVariantResults:
      type: object
      properties:
        variant_id:
          type: string
          format: uuid
        name:
          type: string
        is_control:
          type: boolean
        weight:
          type: integer
        assigned_units:
          type: integer
          minimum: 0
          description: "Distinct units with a conversation in this variant: the debt when
            the call has one, else the call."
        conversations:
          type: integer
          minimum: 0
        closed:
          type: integer
          minimum: 0
          description: Conversations with their terminal outcome.
        valid:
          type: integer
          minimum: 0
          description: Closed with an outcome other than `failed` and `voicemail`; the
            denominator of the rates.
        failed:
          type: integer
          minimum: 0
        ptp:
          type: integer
          minimum: 0
          description: Valid conversations that ended `ptp` or `payment_taken`.
        handoffs:
          type: integer
          minimum: 0
          description: Valid conversations a human took over.
        contained:
          type: integer
          minimum: 0
          description: Valid conversations with no human handoff and an outcome that is
            neither `handoff` nor `handoff_failed`.
        violating:
          type: integer
          minimum: 0
          description: Closed conversations with at least one compliance violation.
        ptp_rate:
          type:
            - number
            - "null"
        contained_rate:
          type:
            - number
            - "null"
        handoff_rate:
          type:
            - number
            - "null"
          description: Better is LESS.
        failed_rate:
          type:
            - number
            - "null"
          description: Over all closed conversations.
        compliance_violation_rate:
          type:
            - number
            - "null"
          description: Over all closed conversations.
        provider_error_rate:
          type:
            - number
            - "null"
          description: Closed conversations with some provider error over the closed ones
            that have metrics; null when none has.
        metrics_coverage:
          type: object
          description: How many closed conversations published metrics. A runtime that
            does not publish yet shows here, not as a clean 0.
          properties:
            with_metrics:
              type: integer
            closed:
              type: integer
        consumption:
          type: object
          description: What the closed calls consumed, in the unit the tenant is billed in
            (minutes). Both fields are null when no closed call has a duration.
          properties:
            minutes_total:
              type:
                - number
                - "null"
            minutes_per_call:
              type:
                - number
                - "null"
        first_audio_ms_p95:
          type: object
          description: The per-call p95 of the first-audio latency.
          properties:
            n:
              type: integer
            mean:
              type:
                - number
                - "null"
            variance:
              type:
                - number
                - "null"
            p50_mean:
              type:
                - number
                - "null"
              description: Mean of the per-call p50.
            p95:
              type:
                - number
                - "null"
              description: 95th percentile of the per-call p95.
        cost:
          type: object
          description: "Whether the provider cost of the calls is known. The amount is
            never published: it is the platform's cost, not a price (the tenant
            is billed per minute)."
          properties:
            status:
              type: string
              enum:
                - priced
                - partial
                - unpriced
              description: "`unpriced` when no call with metrics has a frozen cost: it is
                never shown as 0."
            n:
              type: integer
              description: Calls with a frozen cost.
            unpriced_calls:
              type: integer
    AiAgentExperimentVerdict:
      type: object
      required:
        - status
        - reason
        - winner
        - withheld_by
        - comparisons
        - sample_floor
      properties:
        status:
          type: string
          enum:
            - insufficient_data
            - no_significant_difference
            - winner
        reason:
          type:
            - string
            - "null"
          enum:
            - unpriced
            - null
          description: Why a verdict that could be read is `insufficient_data` for a
            reason other than sample size.
        winner:
          oneOf:
            - type: "null"
            - type: object
              properties:
                variant_id:
                  type: string
                  format: uuid
                versus_variant_id:
                  type: string
                  format: uuid
                  description: The control, or (when the control wins) the challenger with the
                    weakest evidence.
                p_value:
                  type: number
                  description: Bonferroni-adjusted.
                difference:
                  type: number
                  description: Challenger minus control (the winner minus the other arm when the
                    control wins), in the unit of the metric. Absent when the
                    primary metric is `cost_per_call`.
                ci_low:
                  type: number
                  description: Absent when the primary metric is `cost_per_call`.
                ci_high:
                  type: number
                  description: Absent when the primary metric is `cost_per_call`.
        withheld_by:
          oneOf:
            - type: "null"
            - type: string
              enum:
                - allocation_warning
        comparisons:
          type: array
          description: One entry per challenger; empty while the sample floor is not met.
          items:
            type: object
            properties:
              variant_id:
                type: string
                format: uuid
              value:
                type: number
                description: The challenger's rate or mean. Absent when the primary metric is
                  `cost_per_call`.
              difference:
                type: number
                description: Challenger minus control. Absent when the primary metric is
                  `cost_per_call`.
              z:
                type:
                  - number
                  - "null"
                description: Null when both arms have no variance and different means.
              p_value:
                type: number
              adjusted_p_value:
                type: number
              ci_low:
                type: number
                description: Absent when the primary metric is `cost_per_call`.
              ci_high:
                type: number
                description: Absent when the primary metric is `cost_per_call`.
              significant:
                type: boolean
        sample_floor:
          type: object
          properties:
            min_n:
              type: integer
            min_successes:
              type:
                - integer
                - "null"
              description: Null for a mean.
            met:
              type: boolean
    ExperimentVariantOverrides:
      type: object
      description: >
        What a variant changes about its campaign: a CLOSED list. Any other key
        is

        refused with `422` (never ignored).
      additionalProperties: false
      properties:
        script:
          type: object
          description: Goes through the campaign's own write validators (an `sms_body`
            needs «STOP»).
          required:
            - field
            - text
          properties:
            field:
              type: string
              enum:
                - tts_prompt
                - voicemail_message
                - sms_body
            text:
              type: string
        window:
          type: object
          description: A SUBSET of the campaign's calling window; it never widens it.
          required:
            - start
            - end
          properties:
            start:
              type: string
              example: 10:00
            end:
              type: string
              example: 16:00
        min_gap_seconds:
          type: integer
          minimum: 1
          maximum: 2592000
        email_template_id:
          type: string
          format: uuid
          description: An APPROVED template of the tenant.
    ExperimentVariantWrite:
      type: object
      required:
        - name
        - weight
      properties:
        name:
          type: string
          maxLength: 200
        weight:
          type: integer
          minimum: 1
          maximum: 1000
          description: Share of the traffic, relative to the sum of the weights.
        is_control:
          type: boolean
          default: false
          description: Exactly one variant of the experiment is the control.
        overrides:
          $ref: "#/components/schemas/ExperimentVariantOverrides"
    ExperimentWrite:
      type: object
      description: "`POST` needs `name`, `primary_metric` and `variants`; `PATCH`
        takes `name` and/or `variants`."
      properties:
        name:
          type: string
          maxLength: 200
        primary_metric:
          type: string
          enum:
            - contact_rate
            - promise_rate
        variants:
          type: array
          minItems: 2
          maxItems: 5
          items:
            $ref: "#/components/schemas/ExperimentVariantWrite"
    ExperimentVariant:
      type: object
      properties:
        id:
          type: string
          format: uuid
        position:
          type: integer
          description: Order `Assignment.pick/3` walks the variants in (0-based).
        name:
          type: string
        is_control:
          type: boolean
        weight:
          type: integer
        overrides:
          $ref: "#/components/schemas/ExperimentVariantOverrides"
    Experiment:
      type: object
      properties:
        id:
          type: string
          format: uuid
        campaign_id:
          type: string
          format: uuid
        name:
          type: string
        state:
          type: string
          enum:
            - draft
            - running
            - ended
        primary_metric:
          type: string
          enum:
            - contact_rate
            - promise_rate
        started_at:
          type:
            - string
            - "null"
          format: date-time
        ended_at:
          type:
            - string
            - "null"
          format: date-time
        variants:
          type: array
          items:
            $ref: "#/components/schemas/ExperimentVariant"
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    ExperimentResults:
      type: object
      required:
        - experiment
        - variants
        - unattributed_attempts
        - verdict
        - allocation_warning
        - allocation
      properties:
        experiment:
          type: object
          properties:
            id:
              type: string
              format: uuid
            campaign_id:
              type: string
              format: uuid
            name:
              type: string
            state:
              type: string
              enum:
                - draft
                - running
                - ended
            primary_metric:
              type: string
              enum:
                - contact_rate
                - promise_rate
            started_at:
              type:
                - string
                - "null"
              format: date-time
            ended_at:
              type:
                - string
                - "null"
              format: date-time
        variants:
          type: array
          items:
            $ref: "#/components/schemas/ExperimentVariantResults"
        unattributed_attempts:
          type: integer
          minimum: 0
          description: Attempts stamped with the experiment but with no variant, or one
            the experiment does not have. Non-zero means the stamp and the
            experiment disagree.
        verdict:
          $ref: "#/components/schemas/ExperimentVerdict"
        allocation_warning:
          type: boolean
          description: The observed split of assigned debts deviates from the weights
            (chi-square, alpha 0.001). A `winner` is never emitted while it is
            `true`.
        allocation:
          type: object
          properties:
            chi_square:
              type: number
            degrees_of_freedom:
              type: integer
              minimum: 0
            p_value:
              type: number
            warning:
              type: boolean
    ExperimentVariantResults:
      type: object
      properties:
        variant_id:
          type: string
          format: uuid
        name:
          type: string
        is_control:
          type: boolean
        weight:
          type: integer
        assigned_debts:
          type: integer
          minimum: 0
          description: Distinct debts with an attempt in this variant (the unit of
            randomization).
        attempts:
          type: integer
          minimum: 0
        not_placed:
          type: integer
          minimum: 0
          description: Attempts that never left (outside every denominator).
        placed:
          type: integer
          minimum: 0
          description: "`attempts` minus `not_placed`; the denominator of the rates."
        contacts:
          type: integer
          minimum: 0
          description: Placed attempts answered by a person (an answering machine is not a
            contact). For an SMS or email campaign, placed messages that were
            `delivered`.
        promises:
          type: integer
          minimum: 0
          description: Placed attempts with at least one non-cancelled promise. Always 0
            for an SMS or email campaign (a promise is attributed to a call
            attempt).
        abandoned:
          type: integer
          minimum: 0
          description: Contacts that ended with no agent to take them.
        successes:
          type: integer
          minimum: 0
          description: "`contacts` or `promises`, per the experiment's `primary_metric`."
        contact_rate:
          type:
            - number
            - "null"
          description: "`contacts / placed`; null when nothing was placed."
        promise_rate:
          type:
            - number
            - "null"
          description: "`promises / placed`; null when nothing was placed and always null
            for an SMS or email campaign."
        abandon_rate:
          type:
            - number
            - "null"
          description: "`abandoned / contacts`; null when there were no contacts and
            always null for an SMS or email campaign."
    ExperimentVerdict:
      type: object
      required:
        - status
        - winner
        - withheld_by
        - comparisons
        - sample_floor
      properties:
        status:
          type: string
          enum:
            - insufficient_data
            - no_significant_difference
            - winner
        winner:
          oneOf:
            - type: "null"
            - type: object
              properties:
                variant_id:
                  type: string
                  format: uuid
                versus_variant_id:
                  type: string
                  format: uuid
                  description: The control, or (when the control wins) the challenger with the
                    weakest evidence.
                p_value:
                  type: number
                  description: Bonferroni-adjusted.
                difference:
                  type: number
                  description: Winner minus the other arm, in rate points.
                ci_low:
                  type: number
                ci_high:
                  type: number
        withheld_by:
          oneOf:
            - type: "null"
            - type: string
              enum:
                - allocation_warning
          description: Set when a would-be winner was withheld because of the allocation
            warning.
        comparisons:
          type: array
          description: One entry per challenger; empty while the sample floor is not met.
          items:
            type: object
            properties:
              variant_id:
                type: string
                format: uuid
              rate:
                type: number
              difference:
                type: number
                description: Challenger minus control.
              z:
                type: number
              p_value:
                type: number
              adjusted_p_value:
                type: number
              ci_low:
                type: number
              ci_high:
                type: number
              significant:
                type: boolean
        sample_floor:
          type: object
          properties:
            min_placed:
              type: integer
            min_successes:
              type: integer
            met:
              type: boolean
    Campaign:
      type: object
      properties:
        id:
          type: string
          format: uuid
        tenant_id:
          type: string
          format: uuid
        name:
          type: string
        environment:
          type: string
          enum:
            - test
            - live
          description: Environment of the `dd_` key that created the campaign (core#2876);
            server-set, read-only.
        state:
          type: string
          enum:
            - draft
            - running
            - paused
            - completed
            - archived
            - suspended_balance
            - exhausted
        campaign_type:
          type: string
          enum:
            - predictive
            - progressive
            - preview
            - manual
            - tts_blast
            - tts_ivr
            - ivr_cascade
            - survey
            - ai_voice
            - sms_blast
            - email_blast
        channel:
          type: string
          enum:
            - voice
            - sms
            - email
          description: Derived from the type profile — which runner the lifecycle
            dispatches to.
        sms_body:
          oneOf:
            - type: string
            - type: "null"
        email_template_id:
          oneOf:
            - type: string
              format: uuid
            - type: "null"
          description: The approved email template version an `email_blast` campaign sends.
        email_template_variables:
          type: object
          description: Static values of the template's declared variables (`email_blast`).
        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."
        calling_window_weekly:
          oneOf:
            - $ref: "#/components/schemas/CampaignWeeklyWindow"
            - type: "null"
          description: The per-day window as stored, normalised to `HH:MM:SS`. `null` = no
            weekly window. See `CampaignWrite.calling_window_weekly`.
        starts_at:
          oneOf:
            - type: string
              pattern: ^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}$
            - type: "null"
          description: Wall-clock instant, as stored (no offset), when the platform starts
            this campaign if it is still `draft`; read in `default_timezone`.
            `null` = no scheduled start. See `CampaignWrite.starts_at`.
        ends_at:
          oneOf:
            - type: string
              pattern: ^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}$
            - type: "null"
          description: Wall-clock instant, as stored (no offset), when the platform
            completes this campaign if it is `running`; read in
            `default_timezone`. `null` = no scheduled end. See
            `CampaignWrite.ends_at`.
        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.
        voicemail_optout_number:
          oneOf:
            - type: string
            - type: "null"
          description: The NANP toll-free opt-out number the composed voicemail gives, as
            stored; `null` = no voicemail. See
            `CampaignWrite.voicemail_optout_number`.
        voicemail_message:
          oneOf:
            - type: string
            - type: "null"
          description: The voicemail text as written, with its `{optout_number}` marker;
            `null` = no voicemail. See `CampaignWrite.voicemail_message`.
        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
    CampaignDayWindow:
      type: object
      description: "One weekday's window, within the day: `start` strictly before
        `end`. `HH:MM` is accepted and stored as `HH:MM:SS`. No offset: the
        window is read in the zone the campaign reads the hour in."
      required:
        - start
        - end
      additionalProperties: false
      properties:
        start:
          type: string
          pattern: ^([01][0-9]|2[0-3]):[0-5][0-9](:[0-5][0-9])?$
        end:
          type: string
          pattern: ^([01][0-9]|2[0-3]):[0-5][0-9](:[0-5][0-9])?$
    CampaignWeeklyWindow:
      type: object
      description: Per-weekday windows keyed `mon`…`sun`; at least one day. A day that
        is not a key has no window and is closed.
      minProperties: 1
      additionalProperties: false
      properties:
        mon:
          $ref: "#/components/schemas/CampaignDayWindow"
        tue:
          $ref: "#/components/schemas/CampaignDayWindow"
        wed:
          $ref: "#/components/schemas/CampaignDayWindow"
        thu:
          $ref: "#/components/schemas/CampaignDayWindow"
        fri:
          $ref: "#/components/schemas/CampaignDayWindow"
        sat:
          $ref: "#/components/schemas/CampaignDayWindow"
        sun:
          $ref: "#/components/schemas/CampaignDayWindow"
      example:
        mon:
          start: 09:00:00
          end: 17:00:00
        tue:
          start: 09:00:00
          end: 17:00:00
        sat:
          start: 10:00:00
          end: 14:00:00
    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
            - email_blast
          default: predictive
        sms_body:
          type: string
          description: REQUIRED for `sms_blast`; must contain the STOP opt-out notice;
            supports merge vars.
        email_template_id:
          type: string
          format: uuid
          description: "REQUIRED for `email_blast`: an APPROVED email template version of
            this account (`not a template of this account` / `must be an
            approved template` → `400 invalid`)."
        email_template_variables:
          type: object
          description: Static values (string or number) for the variables the template
            declares; validated against its declaration at write time (`400
            invalid`).
        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
        starts_at:
          oneOf:
            - type: string
              pattern: ^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}$
            - type: "null"
          description: "The calendar window's start (R8, core#1501): a WALL-CLOCK instant
            such as `2026-11-02T09:00:00`, read in the campaign's
            `default_timezone`. A UTC offset or `Z` is refused (`400 invalid`):
            the value has no zone of its own, so it cannot disagree with the
            campaign's. While the tenant has the `campaign_window_enabled` flag
            on, a campaign still in `draft` is started by the platform once that
            local time is reached (an append-only `campaign_state_events` row
            records it). A campaign a human has paused or the platform has
            `exhausted` is never restarted by it. `null` clears it. Without the
            flag, or without a value, nothing changes: the campaign starts only
            when `POST /v1/campaigns/{id}/start` says so."
        ends_at:
          oneOf:
            - type: string
              pattern: ^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}$
            - type: "null"
          description: "The calendar window's end: same format and zone as `starts_at`,
            and later than it when both are set. While the flag is on, a
            `running` campaign is `completed` by the platform once that local
            time is reached. `completed` is terminal, so a campaign a human
            starts after its `ends_at` is completed again on the next pass:
            clear `ends_at` first. `null` clears it."
        calling_window_destination_local:
          type: boolean
        calling_window_weekly:
          oneOf:
            - $ref: "#/components/schemas/CampaignWeeklyWindow"
            - type: "null"
          description: "One window per weekday, read in the SAME zone and on the SAME
            clock as `calling_window_start`/`_end` (`default_timezone`, or each
            destination zone with `calling_window_destination_local`). It only
            NARROWS: it is ANDed with the single window and with the legal quiet
            hours, which it never widens. A day left out is CLOSED. Sending it
            REPLACES the whole weekly window; `null` removes it. A value the
            dialer could not read is refused with `422 unprocessable` and
            nothing is stored: an unknown or repeated day, an empty object, a
            day without exactly `start` and `end`, a time that is not `HH:MM` or
            `HH:MM:SS` (an offset such as `Z` or `+05:00`, a fraction of a
            second), `start` not before `end`, or a window reaching past the day
            — a day's window does not cross midnight, and the day ends at
            `23:59:59` (`24:00` is not a time of day). This is a tenant
            preference, not a legal day-of-week or holiday rule."
        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. A detected machine gets the
            composed voicemail only when `voicemail_optout_number` and
            `voicemail_message` are complete (see there); otherwise it is hung
            up on.
        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.
        voicemail_optout_number:
          type: string
          pattern: ^(\+1(800|833|844|855|866|877|888)[0-9]{7})?$
          description: "The toll-free number the voicemail gives so the consumer can call
            back and opt out (47 CFR §64.1200(b)(3)). Must be a NANP toll-free
            number in E.164 AND an ACTIVE `optout` inbound route of this tenant
            (`POST /v1/inbound-routes` with `dest_type: \"optout\"`): calling it
            records the caller on this tenant's do-not-call list. Anything else
            is `400 invalid` naming the field, and nothing is stored. The dialer
            re-checks the route on every tick: disabling it silences the
            voicemail. `\"\"` clears it."
        voicemail_message:
          type: string
          maxLength: 2000
          description: "The text left on an answering machine (AMD machine verdict) by a
            `tts_blast`, `tts_ivr`, `ivr_cascade` or `survey` campaign; every
            other type hangs up on a machine. Must contain the `{optout_number}`
            marker where the number is spoken (`400 invalid` otherwise). What
            plays is COMPOSED by the dialer, never a file: the tenant's stored
            artificial-voice disclosure (its `{optout_digit}` replaced by this
            campaign's key), a space, then this message with the marker replaced
            by the number read digit by digit (`8 0 0, 5 5 5, 0 1 2 3`),
            rendered like the prompt. With any piece missing — AMD off, no
            playable prompt, no number, no marker, the route not active — no
            voicemail is left and a machine is hung up on. `\"\"` 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
    PreflightSignal:
      type: object
      description: >-
        One signal of the preflight's list health, over the evaluated leads.
        `blocked` counts every lead

        this signal refuses, INCLUDING the `no_verificable` ones (a signal that
        cannot decide blocks).

        `status` is `no_verificable` when any lead was unverifiable or the
        cell-level premise is (DNC list

        without a fresh snapshot, no RND provider), else `blocking` when
        `blocked > 0`, else `ok`.
      required:
        - status
        - blocked
        - no_verificable
        - rules
      properties:
        status:
          type: string
          enum:
            - ok
            - blocking
            - no_verificable
        blocked:
          type: integer
          minimum: 0
        no_verificable:
          type: integer
          minimum: 0
        rules:
          type: object
          description: Blocked leads per rule id (for `state`, the state-matrix label the
            lead falls under).
          additionalProperties:
            type: integer
    PreflightDncSnapshot:
      type: object
      required:
        - status
        - age_days
        - max_age_days
        - source
        - record_count
      properties:
        status:
          type: string
          enum:
            - provisioned
            - stale
            - absent
        age_days:
          type:
            - integer
            - "null"
          description: Days since the snapshot was imported; null when absent.
        max_age_days:
          type: integer
          description: Freshness ceiling for this list.
        source:
          type: string
          description: Provenance string of the snapshot (`none` when absent).
        record_count:
          type:
            - integer
            - "null"
    ComplianceDncSource:
      type: object
      required:
        - status
        - source
        - record_count
        - updated_at
        - expires_at
        - age_days
        - max_age_days
      properties:
        status:
          type: string
          enum:
            - provisioned
            - stale
            - absent
          description: "`stale` and `absent` are refused by the gate: no scrub is claimed
            over them."
        source:
          type: string
          description: Provenance string of the load (`none` when absent).
        record_count:
          type:
            - integer
            - "null"
        updated_at:
          type:
            - string
            - "null"
          format: date-time
          description: When the SOURCE data was cut; null when absent.
        expires_at:
          type:
            - string
            - "null"
          format: date-time
          description: First instant at which the gate treats the snapshot as `stale`;
            null when absent.
        age_days:
          type:
            - integer
            - "null"
          description: Whole days since `updated_at` (0 for a future date); null when
            absent.
        max_age_days:
          type: integer
          description: Freshness ceiling for this source.
    ComplianceSources:
      type: object
      required:
        - as_of
        - dnc
        - litigator
        - rnd
      properties:
        as_of:
          type: string
          format: date-time
          description: The instant every age and status was computed at.
        dnc:
          type: object
          required:
            - federal
            - state
            - state_registries
            - internal_global
          properties:
            federal:
              $ref: "#/components/schemas/ComplianceDncSource"
            state:
              $ref: "#/components/schemas/ComplianceDncSource"
            state_registries:
              type: object
              description: Every declared per-state DNC registry, keyed by state code, each
                against its own ceiling.
              additionalProperties:
                $ref: "#/components/schemas/ComplianceDncSource"
            internal_global:
              $ref: "#/components/schemas/ComplianceDncSource"
        litigator:
          type: object
          required:
            - available
            - covered_by
          properties:
            available:
              type: boolean
              enum:
                - false
              description: Core keeps no litigator list of its own.
            covered_by:
              type: string
              description: The DNC list whose rows include the platform's litigators.
        rnd:
          type: object
          required:
            - provider
            - status
            - last_collected_at
            - valid_since
            - database_updated_at
          properties:
            provider:
              type: object
              required:
                - status
                - provider
              properties:
                status:
                  type: string
                  enum:
                    - declared
                    - stub
                    - no_verificable
                provider:
                  type:
                    - string
                    - "null"
                  description: Name of the declared provider; null when none.
            status:
              type: string
              enum:
                - current
                - stale
                - never
              description: The tenant's last collection against the cycle floor
                (`valid_since`).
            last_collected_at:
              type:
                - string
                - "null"
              format: date-time
              description: Last time a results file was collected for the calling tenant.
            valid_since:
              type: string
              format: date-time
              description: Answers collected at or before this instant no longer back the safe
                harbor.
            database_updated_at:
              type:
                - string
                - "null"
              format: date-time
              description: Last update of the administrator's database the platform observed;
                null while only the calendar rule applies.
    CampaignStatsResponse:
      type: object
      required:
        - campaign_id
        - state
        - running
        - stats
      properties:
        campaign_id:
          type: string
          format: uuid
        state:
          type: string
          enum:
            - draft
            - running
            - paused
            - completed
            - archived
            - suspended_balance
            - exhausted
        running:
          type: boolean
        stats:
          oneOf:
            - $ref: "#/components/schemas/VoiceRunnerStats"
            - $ref: "#/components/schemas/SmsRunnerStats"
            - type: "null"
    VoiceRunnerStats:
      type: object
      required:
        - dialed
        - blocked
        - finished
        - reaped
        - throttled
        - pool_unavailable
        - pool_unavailable_reason
        - no_agent_seat
        - no_rate
        - tenant_throttled
        - tenant_throttle_reason
        - tenant_throttle_by_reason
        - tenant_throttle_last_at
        - prompt_unavailable
        - prompt_unavailable_reason
        - overdial_disarmed
        - thirty_day_abandonment
        - overdial_margin
        - in_flight
        - pacing
      properties:
        dialed:
          type: integer
        blocked:
          type: integer
        finished:
          type: integer
        reaped:
          type: integer
        in_flight:
          type: integer
        throttled:
          type: integer
        pool_unavailable:
          type: integer
          description: "Ticks that did not dial because the caller-ID pool gave no usable
            number: every DID of the campaign's pool or `caller_ids` is in
            quarantine or retired, an unverified DID is left out by the tenant's
            `did_ownership_proof = enforce`, the DID registry could not be read,
            or the per-number daily reuse cap is spent. Cumulative for the
            runner's lifetime, one per silent tick; a `running` campaign whose
            `dialed` stops moving while this grows is not starved of leads
            (core#1058). The cause is in `pool_unavailable_reason`."
        pool_unavailable_reason:
          type:
            - string
            - "null"
          enum:
            - zero_active_dids
            - did_unverified
            - resolver_failed
            - reuse_cap_exhausted
            - reuse_cap_failed
            - no_caller_id_source
            - null
          description: "Why the caller-ID pool gave no usable number on the last silent
            tick of the current streak, or `null` when there is no streak: no
            tick has been silent, a later tick placed a call, or the campaign
            went silent for another cause since. `zero_active_dids`: none of the
            campaign's DIDs is active (all in quarantine or retired, or none
            registered). `did_unverified`: active DIDs exist, and only the
            tenant's `did_ownership_proof = enforce` left them out because their
            ownership is not verified. `resolver_failed`: the DID registry could
            not be read. `reuse_cap_exhausted`: every number already presented
            today as many times as the cap allows. `reuse_cap_failed`: the reuse
            counter could not be read. `no_caller_id_source`: the campaign names
            no caller-ID source at all (empty `caller_id_pool` and
            `caller_ids`), so it does not dial until a DID is assigned
            (core#2492). Clears on its own once a tick dials. The
            `campaign.stats` WebSocket frame carries the same field
            (core#2528)."
        no_agent_seat:
          type: integer
          description: "Ticks an agent campaign did not dial because no agent seat was
            free. Cumulative for the runner's lifetime. Not an abandonment:
            nobody answered; the dial never left. Check an agent in. A tick
            halted for this does not set `pool_unavailable_reason`."
        no_rate:
          type: integer
          description: Leads refused because no sell rate covers their destination
            (prepaid tenants). The lead cools down and the campaign keeps
            dialing the rest; topping up the wallet does not clear it.
        tenant_throttled:
          type: integer
          description: "Ticks halted because the whole tenant is at a limit: its plan is
            not active, a voice-AI budget or the pre-KYC spending ceiling is
            reached, the prepaid balance, postpaid credit or plan allowance ran
            out, or the capacity gate (calls per second, channels, the carrier's
            calls per second) refused the dial. Cumulative for the runner's
            lifetime, one per halted tick. Split by reason in
            `tenant_throttle_by_reason`."
        tenant_throttle_reason:
          type:
            - string
            - "null"
          enum:
            - plan_inactive
            - spend_cap
            - account_spend_cap
            - wallet_insufficient
            - plan_exhausted
            - exposure_limit
            - daily_spend_limit
            - tenant_cps
            - tenant_channels
            - carrier_cps
            - capacity_unavailable
            - ai_capacity
            - tenant_share
            - ai_capacity_undeclared
            - ai_capacity_unavailable
            - synthetic_tenant
            - spend_guard_unavailable
            - null
          description: "The tenant-wide brake in force, or `null` when none is. It is set
            by the tick it halts, and cleared by a later tick that places a call
            and does not end on a brake; a tick with nothing to dial leaves it
            as it was. `plan_inactive`: the tenant's plan is not active.
            `spend_cap`: the tenant's voice-AI budget for the month is reached.
            `account_spend_cap`: the account's voice-AI budget for the month,
            summed over all its tenants, is reached. `wallet_insufficient`: the
            prepaid balance does not cover a call. `plan_exhausted`: the
            postpaid credit, or the subscription's included allowance for the
            period, is used up. `exposure_limit`: the spending ceiling that
            applies until identity verification (KYC) is complete is reached; it
            grows each day. `daily_spend_limit`: the day's spending cap of the
            account's trust level is reached; it resets the next UTC day or when
            the account moves up a level. `tenant_cps` / `tenant_channels`: the
            tenant's calls-per-second or concurrent-channel limit.
            `carrier_cps`: the chosen carrier's calls-per-second limit.
            `capacity_unavailable`: the capacity gate could not decide, so it
            refused. `ai_capacity`: the voice-AI sessions the environment can
            hold are all in use (core#1326); it is shared by every tenant, and a
            slot frees when a voice-AI call ends. `ai_capacity_undeclared`: the
            environment has not declared how many voice-AI sessions it can hold,
            so no voice-AI call is placed. `tenant_share`: the tenant already
            holds its share of the voice-AI session pool
            (`ai_session_tenant_share_pct`) while another tenant is contending
            for it (core#2419). `ai_capacity_unavailable`: the voice-AI session
            gate could not decide, so it refused. `synthetic_tenant`: the tenant
            is a synthetic demo tenant, which originates no outbound traffic
            (core#2251). `spend_guard_unavailable`: core could not read the
            tenant's spend state (no cached snapshot and the load failed), so
            house-cost traffic is refused until it can (core#2368)."
        tenant_throttle_by_reason:
          type: object
          additionalProperties:
            type: integer
          description: "`tenant_throttled` split by reason (the values of
            `tenant_throttle_reason`). Empty until a tenant-wide brake halts a
            tick."
        tenant_throttle_last_at:
          type:
            - string
            - "null"
          format: date-time
          description: When a tenant-wide brake last halted a tick, or `null` if none has.
            It stays after the brake clears.
        prompt_unavailable:
          type: integer
          description: "Times an artificial-voice campaign (TTS, IVR, survey) was refused
            a dial because it has nothing playable: no prompt text, or a prompt
            whose text-to-speech render failed (once per blocked tick and once
            per failed render). Such a campaign never places a call (core#1283);
            a failed render is retried and the campaign resumes dialing on its
            own once it renders."
        prompt_unavailable_reason:
          type:
            - string
            - "null"
          enum:
            - prompt_missing
            - disclosure_missing
            - optout_instructions_missing
            - prompt_render_failed
            - ivr_prompt_render_failed
            - prompt_rendering
            - null
          description: "Why the last dialing tick refused to originate for want of a
            playable prompt, or `null` when it did not. `prompt_missing`: the
            campaign has no prompt text. `disclosure_missing`: the tenant has no
            valid stored artificial-voice disclosure, which every TTS / IVR /
            survey prompt plays before the script.
            `optout_instructions_missing`: the call could not tell the consumer
            how to opt out (47 CFR §64.1200(b)(3); core#1305): the stored
            disclosure has no `{optout_digit}` marker, or the campaign has the
            prior `ai_voice` flag and no valid `optout_digit` (the compliance
            gate judges it as the AI lane and does not check the key). Any other
            campaign with no `optout_digit` is not reported here: the compliance
            gate refuses each of its attempts
            (`tcpa.artificial_voice.optout.v1`). `prompt_render_failed`: the
            prompt did not render. `ivr_prompt_render_failed`: a prompt of the
            IVR menu did not render. `prompt_rendering`: the first render of the
            current prompt or menu is still in progress (not a failure, and not
            counted)."
        overdial_disarmed:
          type:
            - string
            - "null"
          enum:
            - thirty_day_ceiling
            - thirty_day_no_margin
            - thirty_day_no_data
            - thirty_day_unreadable
            - null
          description: "Why the campaign's 30-day abandonment rate keeps the predictive
            over-dial disarmed (core#1369), or `null` when it does not.
            `thirty_day_ceiling`: the running 30-day period of
            `/v1/campaigns/{id}/abandonment` is over the 3 % ceiling (47 CFR
            §64.1200(a)(7), 16 CFR §310.4(b)(4)(i)). `thirty_day_no_margin`
            (core#1401): the period is under the ceiling, but one more abandon
            would take it over — fewer than 33 live answers with no abandon, or
            too many abandons for another; see `overdial_margin`.
            `thirty_day_no_data`: the running period has no placed dial yet.
            `thirty_day_unreadable`: the rate could not be read. Disarmed, the
            campaign keeps dialing seat-first (one reserved seat per call) and
            re-arms on its own once the rate is back under the ceiling. The rate
            is re-read at most every 30 s, and only for a campaign that could
            otherwise over-dial; for any other campaign this is `null`."
        thirty_day_abandonment:
          type:
            - object
            - "null"
          description: The running 30-day period the over-dial gate last read (core#1369),
            or `null` when it read none (a campaign that cannot over-dial, or a
            failed read). The same figures as that period in
            `/v1/campaigns/{id}/abandonment`, without the per-reason breakdown.
          properties:
            index:
              type: integer
            starts_at:
              type: string
              format: date-time
            abandoned:
              type: integer
            answered_live:
              type: integer
            rate:
              type:
                - number
                - "null"
            exceeds_ceiling:
              type: boolean
        overdial_margin:
          type:
            - object
            - "null"
          description: "The over-dial margin of the period in `thirty_day_abandonment`
            (core#1401), or `null` when no period was read. `margin` is how many
            calls could all end abandoned without taking the period over 3 %
            (`⌊(3·answered_live − 100·abandoned) / 97⌋`, never below 0). `spent`
            is what may already be one the read did not count: the seatless
            dials still in the air and not bridged, every call that ended since
            the read, and the seatless dials a previous runner of the campaign
            left with no finalize. `room` is what is left (`margin − spent`,
            never below 0): the most seatless dials the next tick can originate.
            With `room` 0 the campaign dials seat-first until a read gives
            margin back."
          properties:
            margin:
              type: integer
            spent:
              type: integer
            room:
              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
      required:
        - sent
        - duplicate
        - blocked
        - failed
        - pending_cooldown
        - send_unavailable_reason
      properties:
        sent:
          type: integer
        duplicate:
          type: integer
        blocked:
          type: integer
        failed:
          type: integer
        pending_cooldown:
          type: integer
        send_unavailable_reason:
          type:
            - string
            - "null"
          enum:
            - provider_unresolved
            - caller_ids_no_active_did
            - caller_id_pool_no_active_did
            - default_from_invalid
            - did_unverified
            - outside_calling_window
            - null
          description: "Why the last tick sent nothing, or `null` when it did not stop at
            a gate. It is cleared by the first later tick that passes the gates.
            `outside_calling_window` (core#2604): the tick fell outside the
            campaign's calling window, so nothing is sent until the window opens
            (not set when `calling_window_destination_local` is on, where the
            window is decided per lead). `provider_unresolved`: no SMS provider
            resolves for the account or site; configure one.
            `caller_ids_no_active_did`: none of the campaign's `caller_ids` is
            an ACTIVE number of the account (core#2329); register the number in
            the DID registry or choose a sender the account owns.
            `caller_id_pool_no_active_did`: the campaign's `caller_id_pool` has
            no active number. `default_from_invalid`: the campaign has no sender
            of its own and the provider's `default_from_e164` is not an E.164
            number the account owns; set a sender on the campaign or fix the
            provider's default. `did_unverified` (core#2415): the account's
            `did_ownership_proof` is `enforce` and the only ACTIVE numbers the
            campaign would send from are registered but have no proven ownership
            (`verified_at` null); the platform admin verifies them with `POST
            /v1/admin/tenants/{id}/dids/{did_id}/verify`."
    CreateCallRequest:
      type: object
      required:
        - to
        - purpose
        - text
      additionalProperties: false
      properties:
        to:
          type: string
          description: Destination, E.164 (`+13125550100`). North American numbers only in
            v1.
        purpose:
          type: string
          enum:
            - transactional
            - informational
            - marketing
          description: Declared purpose of the call. Evidence only, it never relaxes a
            gate. `marketing` with a synthesized voice is refused.
        text:
          type: string
          minLength: 1
          maxLength: 500
          description: The message, at most 500 bytes. Played after the tenant's stored
            disclosure.
        from:
          type: string
          description: Caller-ID, E.164. It must be an active, verified number of the
            tenant; absent, the first usable one is taken.
    CreateCallResult:
      type: object
      required:
        - call_id
        - status
      properties:
        call_id:
          type: string
          format: uuid
          description: Follow the call with `GET /v1/calls/{call_id}`.
        status:
          type: string
          enum:
            - dialing
            - unresolved
          description: "`unresolved`: the switch did not confirm in time and the leg may
            be ringing — do not retry."
    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
        debtor_name:
          oneOf:
            - type: string
              maxLength: 200
            - type: "null"
          description: The debtor's name as the CMS sent it (`null` when it sent none).
        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"
    PreviewOffer:
      type: object
      description: "An account reserved for ONE seat (`POST /v1/agent/preview/next`,
        ADR #347). Past `expires_at` the reservation lapses and the account can
        be offered to anyone."
      required:
        - offer_id
        - campaign_id
        - debt_id
        - offered_at
        - expires_at
        - lead
      properties:
        offer_id:
          type: string
          format: uuid
        campaign_id:
          type: string
          format: uuid
        debt_id:
          type: string
          format: uuid
        offered_at:
          type: string
          format: date-time
        expires_at:
          type: string
          format: date-time
        lead:
          $ref: "#/components/schemas/PreviewLead"
    PreviewSkip:
      type: object
      description: The append-only fact a skip leaves (`POST /v1/agent/preview/skip`).
      required:
        - offer_id
        - campaign_id
        - debt_id
        - reason
        - skipped_at
        - parked_until
      properties:
        offer_id:
          type: string
          format: uuid
        campaign_id:
          type: string
          format: uuid
        debt_id:
          type: string
          format: uuid
        reason:
          type: string
          enum:
            - ""
            - not_now
            - bad_data
            - other
        skipped_at:
          type: string
          format: date-time
        parked_until:
          type: string
          format: date-time
    RewindFilters:
      type: object
      description: All filters optional; unknown `amd_verdicts` answer `422` fail-closed.
      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
            enum:
              - unspecified
              - human
              - machine
              - not_sure
          description: Matched against the latest placed attempt's AMD verdict.
      additionalProperties: false
    RewindRequest:
      description: |
        Either the loose filters or a saved segment, never both: `segment_id`
        with any filter is `400`. A `segment_id` that is unknown, retired or
        another tenant's is `404`; a segment whose stored filters no longer
        validate is `422` (never a wider rewind).
      oneOf:
        - $ref: "#/components/schemas/RewindFilters"
        - type: object
          required:
            - segment_id
          additionalProperties: false
          properties:
            segment_id:
              type: string
              format: uuid
    RewindSegment:
      type: object
      required:
        - id
        - name
        - filters
        - created_by
        - created_at
        - updated_at
        - retired_at
        - retired_by
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
          minLength: 1
          maxLength: 80
        filters:
          $ref: "#/components/schemas/RewindFilters"
        created_by:
          type: string
          description: Taken from the credential, never from the body.
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        retired_at:
          oneOf:
            - type: string
              format: date-time
            - type: "null"
        retired_by:
          oneOf:
            - type: string
            - type: "null"
    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
        debtor_name:
          oneOf:
            - type: string
              maxLength: 200
            - type: "null"
          description: >
            Optional full name of the debtor, free text. Whitespace is collapsed
            and

            trimmed; a blank value is stored as no name. More than 200
            characters

            fails the ITEM (row error `debtor_name should be at most 200
            character(s)`),

            not the batch. Encrypted at rest, not searchable, and never sent to

            analytics, webhooks or logs. Like the other debt fields it is a
            snapshot:

            a re-import that omits it clears the stored name.
        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.
        last_conversation_at:
          type: string
          format: date-time
          description: >
            When the client last spoke with the debtor, from its previous system
            (RFC 3339 with a UTC offset). Without it the Reg F 7-day
            post-contact cooldown (12 CFR 1006.14(b)(2)(i)(B)) starts BLIND on a
            migrated portfolio: the platform only knows the conversations it
            recorded, and could call again inside the window.

            Materialized as a `conversations` row with `source: cms_import`,
            which the gate already reads, so the gate itself is unchanged; the
            debt's `last_conversation_at` anchor advances to it. Re-sending the
            same payload adds nothing, and an OLDER date never moves the anchor
            back. Reports that measure platform activity (penetration,
            abandonment rate, the attempts export) do not count it.

            A malformed value, one without a UTC offset, or one in the future
            fails THAT item (a future date would hold the cooldown open past its
            window); omitting it changes nothing.
        delinquent_since:
          type: string
          format: date
          description: >
            The calendar date the debt went delinquent (`YYYY-MM-DD`), from the
            client's previous system. It is the debt's age: segmentation filters
            such as "delinquent for 31 to 90 days" are computed against it. A
            snapshot, like `debtor_name`: re-importing the debt without it
            clears it. A debt with no date never matches an age filter.

            Anything that is not a real calendar date in that exact form
            (`null`, an empty string, a number, a timestamp, `2026-02-30`) or a
            date in the future fails THAT item; omitting it is fine. In a CSV
            import a blank cell is the same as omitting the field.
        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` and `debtor_name` are absent by construction

        (the read view does not select them): the name is read on the debt
        itself

        (`GET /v1/debts/{id}`) or on the debtor card.
      properties:
        id:
          type: string
          format: uuid
        tenant_id:
          type: string
          format: uuid
        campaign_id:
          oneOf:
            - type: string
              format: uuid
            - type: "null"
          description: >
            The debt's DIRECT slot, as on `Debt`. A debt also in a campaign by a

            list is listed under `?campaign_id=` of that campaign with this
            field

            unchanged.
        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
        dialability:
          $ref: "#/components/schemas/DebtDialability"
    DebtBlockedReason:
      type: string
      enum:
        - debt_state_not_dialable
        - parked
        - no_phones
        - cease_and_desist
        - no_consent
        - consent_revoked
        - dnc_federal
        - dnc_state
        - dnc_internal
        - dnc_unverifiable
        - number_reassigned
        - rnd_unverifiable
        - quiet_hours
        - regf_7in7
        - regf_post_contact
        - state_limit
        - quarantined_number
        - indeterminate
      description: >
        Why an account or a phone is not dialable: a CLOSED vocabulary (ADR
        #269).

        Account-level: `debt_state_not_dialable`, `parked` (the disposition
        engine

        parked it until `next_dialable_at`), `no_phones`, `cease_and_desist` and
        Reg F

        (`regf_7in7`, `regf_post_contact`). Phone-level: `no_consent`,

        `consent_revoked`, `dnc_federal`, `dnc_state`, `dnc_internal` (the
        tenant's

        list or the platform's, not distinguished), `dnc_unverifiable` (a list
        or a

        state registry is not provisioned or is stale), `number_reassigned`,

        `rnd_unverifiable` (the Reassigned Numbers Database cannot vouch for the

        number), `quiet_hours`, `state_limit` (MA, WA and NYC caps, or an
        unresolved

        state under the strict flag) and `quarantined_number`. `indeterminate`
        means

        the platform could not decide (a gate failed, a rule is unknown, the

        destination timezone does not resolve, or the page was too large to
        evaluate):

        it is NEVER permission. A client must show a value it does not know as
        it

        arrives, never as dialable.
    DebtDialabilityConsent:
      type: object
      required:
        - status
        - channel
        - consented_at
      description: |
        The consent on file for the number. Only the state, the channel and the
        instant: never the free-text source.
      properties:
        status:
          type: string
          enum:
            - granted
            - revoked
            - not_on_file
        channel:
          oneOf:
            - type: string
            - type: "null"
        consented_at:
          oneOf:
            - type: string
              format: date-time
            - type: "null"
    DebtDialabilityPhone:
      type: object
      required:
        - phone_masked
        - line_type
        - feed_candidate
        - consent
        - dialable
        - blocked_reasons
      properties:
        phone_masked:
          oneOf:
            - type: string
            - type: "null"
          description: "`***` + the last four digits, never more."
          example: "***8906"
        line_type:
          oneOf:
            - type: string
              enum:
                - mobile
                - landline
                - voip
                - unknown
            - type: "null"
        feed_candidate:
          type: boolean
          description: >
            True for the phone the dialer would call (the account's rank-1
            contact:

            primary first, then the oldest). The others are informative.
        consent:
          $ref: "#/components/schemas/DebtDialabilityConsent"
        dialable:
          type: boolean
          description: Every evaluated gate lets THIS phone through; `blocked_reasons` is
            empty iff true.
        blocked_reasons:
          type: array
          items:
            $ref: "#/components/schemas/DebtBlockedReason"
          description: >
            ALL the reasons, not only the first (gates are not short-circuited),
            in the

            order of the dialer's gate chain.
    DebtDialability:
      type: object
      required:
        - evaluated_at
        - dialable
        - blocked_reasons
        - eligible_at
        - phones
        - phones_truncated
        - not_evaluated_gates
      description: >
        Present only with `include=dialability` (ADR #269). The verdict is "now"

        and ages in seconds: it informs, it never grants permission; the engine

        decides at dial time. It is evaluated with the dialer's OWN gates over
        one

        snapshot read per page.
      properties:
        evaluated_at:
          type: string
          format: date-time
        dialable:
          type: boolean
          description: >
            The phone the dialer would call passes every evaluated gate AND no

            account-level reason applies. NOT a promise that a call will be
            placed:

            see `not_evaluated_gates`.
        blocked_reasons:
          type: array
          items:
            $ref: "#/components/schemas/DebtBlockedReason"
          description: >
            Account reasons plus the reasons of the phone the dialer would call;

            empty iff `dialable`. `["no_phones"]` when the account has no
            contact.
        eligible_at:
          oneOf:
            - type: string
              format: date-time
            - type: "null"
          description: >
            `evaluated_at` when nothing blocks. Otherwise the first instant,
            within

            9 days, at which the time-dependent gates (quiet hours, Reg F, the
            state

            matrix) stop blocking the phone the dialer would call, ONLY when
            nothing

            else blocks it; `null` when another reason applies or no instant
            exists.
        phones:
          type: array
          maxItems: 5
          items:
            $ref: "#/components/schemas/DebtDialabilityPhone"
          description: Up to 5 contacts, in the dialer's rank order. Empty when the
            account could not be evaluated.
        phones_truncated:
          type: boolean
          description: The account has more contacts than `phones` lists.
        not_evaluated_gates:
          type: array
          items:
            type: string
          description: >
            What `dialable` does NOT cover because it depends on the campaign,
            the

            call or the tenant (AI disclosure, artificial voice, caller-ID

            attestation, campaign state, plan, spend caps, pacing).
    ConsumerWindow:
      type: object
      required:
        - status
        - reason
        - at
        - destination_timezone
        - destination_local_time
      description: |
        The next instant a call is permitted by the time-dependent gates
        (`ConsumerCard.evaluated_gates`), or why there is none.
      properties:
        status:
          type: string
          enum:
            - permitted_now
            - scheduled
            - suppressed
            - not_dialable
            - undecidable
          description: >
            `permitted_now`: the gates permit a call at `generated_at`.
            `scheduled`:

            the earliest permitted instant is `at`. `suppressed`: a DNC listing
            or

            a cease-and-desist forbids contact. `not_dialable`: the debt's state
            is

            not dialable. `undecidable`: the card cannot decide and says why in

            `reason`; it is never read as permission.
        reason:
          oneOf:
            - type: string
            - type: "null"
          description: >
            Null for `permitted_now` / `scheduled`. Otherwise a code such as

            `dnc_listed`, `cease_and_desist`, `debt_state_closed`,
            `no_contacts`,

            `tz_unresolved`, `too_many_contacts`, `debts_truncated`,

            `none_within_horizon`, or the `rule_id` of the gate that blocked.
        at:
          oneOf:
            - type: string
              format: date-time
            - type: "null"
          description: Earliest permitted instant (UTC); null when there is none.
        destination_timezone:
          oneOf:
            - type: string
            - type: "null"
          description: IANA zone the quiet-hours gate evaluated first at `at`.
        destination_local_time:
          oneOf:
            - type: string
              format: date-time
            - type: "null"
          description: "`at` in `destination_timezone`, with its UTC offset."
    ConsumerRegF:
      type: object
      required:
        - scope
        - attempts_in_window
        - cap
        - window_days
        - last_conversation_at
        - status
        - eligible_at
      description: The Reg F counter in force for the debt's counter key, as the gate
        reads it.
      properties:
        scope:
          type: string
          enum:
            - debt
            - account
          description: "`account` when student-loan debts share a servicer account (12 CFR
            1006.14(b)(4)), else `debt`."
        attempts_in_window:
          type: integer
          minimum: 0
        cap:
          type: integer
          description: Attempts allowed per window.
          example: 7
        window_days:
          type: integer
          example: 7
        last_conversation_at:
          oneOf:
            - type: string
              format: date-time
            - type: "null"
        status:
          type: string
          enum:
            - ok
            - blocked_7in7
            - blocked_post_contact
        eligible_at:
          oneOf:
            - type: string
              format: date-time
            - type: "null"
          description: First instant at which Reg F alone stops blocking, if nothing else
            is dialed. Quiet hours and the state matrix may push the real
            `window` later.
    ConsumerDebt:
      allOf:
        - $ref: "#/components/schemas/DebtSummary"
        - type: object
          required:
            - reg_f
            - window
          properties:
            debtor_name:
              oneOf:
                - type: string
                  maxLength: 200
                - type: "null"
              description: The debtor's name as the CMS sent it on this debt (core#1529);
                `null` when it sent none.
            reg_f:
              $ref: "#/components/schemas/ConsumerRegF"
            window:
              $ref: "#/components/schemas/ConsumerWindow"
    NegotiationBounds:
      type: object
      required:
        - max_settlement_pct
        - min_payment_cents
        - max_plan_months
      properties:
        max_settlement_pct:
          oneOf:
            - type: integer
              minimum: 0
              maximum: 100
            - type: "null"
          description: Max settlement percentage the agent is authorized to offer, if any.
        min_payment_cents:
          oneOf:
            - type: integer
              minimum: 0
            - type: "null"
          description: Minimum accepted payment in cents, if any.
        max_plan_months:
          oneOf:
            - type: integer
              minimum: 1
            - type: "null"
          description: Max plan length in months the agent is authorized to offer, if any.
    ConsumerCard:
      type: object
      required:
        - id
        - identity
        - generated_at
        - debts
        - debts_truncated
        - cease_and_desist
        - dnc
        - next_window
        - negotiation_bounds
        - promise_max_horizon_days
        - evaluated_gates
        - not_evaluated_gates
      properties:
        id:
          type: string
          description: The `consumer_ref`.
        identity:
          type: string
          enum:
            - consumer_ref
          description: Which key identifies the consumer. core has no consumers entity;
            this is the key Reg F counts under.
        generated_at:
          type: string
          format: date-time
          description: The compliance-clock instant every figure was evaluated at.
        debts:
          type: array
          items:
            $ref: "#/components/schemas/ConsumerDebt"
        debts_truncated:
          type: boolean
          description: True when the consumer has as many debts as the page cap; the
            windows are then `undecidable` (`debts_truncated`).
        cease_and_desist:
          type: object
          required:
            - on_file
            - records
          properties:
            on_file:
              type: boolean
              description: Any record for the consumer suppresses contact on every debt, as
                the consent gate reads it.
            records:
              type: array
              items:
                type: object
                required:
                  - scope
                  - debt_external_ref
                  - received_at
                properties:
                  scope:
                    type: string
                    enum:
                      - consumer
                      - debt
                  debt_external_ref:
                    type: string
                  received_at:
                    type: string
                    format: date-time
        dnc:
          type: object
          required:
            - contacts_total
            - contacts_checked
            - contacts_listed
            - lists
            - truncated
          description: Counts of the consumer's distinct phone numbers on each list. No
            number is published.
          properties:
            contacts_total:
              type: integer
              minimum: 0
            contacts_checked:
              type: integer
              minimum: 0
            contacts_listed:
              type: integer
              minimum: 0
            lists:
              type: object
              required:
                - federal
                - state
                - internal_tenant
                - internal_global
              properties:
                federal:
                  type: integer
                  minimum: 0
                state:
                  type: integer
                  minimum: 0
                internal_tenant:
                  type: integer
                  minimum: 0
                internal_global:
                  type: integer
                  minimum: 0
            truncated:
              type: boolean
              description: True when the consumer has more numbers than the card checks.
        next_window:
          $ref: "#/components/schemas/ConsumerWindow"
        negotiation_bounds:
          oneOf:
            - $ref: "#/components/schemas/NegotiationBounds"
            - type: "null"
          description: Active negotiation policy for this tenant's AI agent, or null if
            none.
        promise_max_horizon_days:
          type: integer
          minimum: 1
          maximum: 365
          description: The tenant's `promise_max_horizon_days` (90 by default) — how many
            days ahead a promise-to-pay may be dated, counted from the current
            UTC day. Lets a client offer only dates the wrap-up will accept.
        evaluated_gates:
          type: array
          items:
            type: string
          description: Rule ids `window` / `next_window` are derived from.
        not_evaluated_gates:
          type: array
          items:
            type: string
          description: Gates `window` / `next_window` do NOT cover.
    DebtSearchRequest:
      type: object
      additionalProperties: false
      description: >
        Filters of `POST /v1/debts/search`; every key is optional and they
        combine

        with AND. Same vocabulary as the query parameters of `GET /v1/debts`.
      properties:
        campaign_id:
          type: string
          format: uuid
        state:
          description: One debt state or several (matches ANY).
          oneOf:
            - type: string
              enum:
                - open
                - in_collection
                - promise_to_pay
                - paid
                - settled
                - disputed
                - closed
            - type: array
              minItems: 1
              items:
                type: string
                enum:
                  - open
                  - in_collection
                  - promise_to_pay
                  - paid
                  - settled
                  - disputed
                  - closed
        debt_type:
          description: One debt type or several (matches ANY).
          oneOf:
            - type: string
              enum:
                - other
                - student_loan
            - type: array
              minItems: 1
              items:
                type: string
                enum:
                  - other
                  - student_loan
        us_state:
          description: One USPS two-letter code of the PRIMARY contact or several (matches
            ANY).
          oneOf:
            - type: string
              pattern: ^[A-Z]{2}$
            - type: array
              minItems: 1
              items:
                type: string
                pattern: ^[A-Z]{2}$
        min_amount_cents:
          type: integer
          minimum: 0
        max_amount_cents:
          type: integer
          minimum: 0
        dialable_by:
          type: string
          format: date-time
        phone:
          type: string
          description: Normalized E.164 of any contact of the debt.
          example: "+13125550100"
        consumer_ref:
          type: string
          maxLength: 128
        external_ref:
          type: string
          maxLength: 128
        limit:
          type: integer
          default: 50
          maximum: 200
          minimum: 1
          description: Page size. With `include` the maximum is 50 and a larger value is a
            400.
        cursor:
          type: string
        include:
          type: array
          uniqueItems: true
          items:
            type: string
            enum:
              - dialability
          description: |
            Extra projections for every row (`["dialability"]`, ADR #269): same
            meaning, cap and cost as `include` on `GET /v1/debts`.
    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.
    DebtListMember:
      type: object
      required:
        - debt_id
        - external_ref
        - added_at
      description: |
        A debt in a list. No phone and no debtor name: `external_ref` is the
        client's own key for the account.
      properties:
        debt_id:
          type: string
          format: uuid
        external_ref:
          type: string
        added_at:
          type: string
          format: date-time
    DebtList:
      type: object
      required:
        - id
        - name
        - size
        - campaign_ids
        - archived
      description: |
        A reusable list. `size` and `campaign_ids` are counted when the list is
        read, never stored. `archived` / `archived_at` are terminal.
      properties:
        id:
          type: string
          format: uuid
        tenant_id:
          type: string
          format: uuid
        name:
          type: string
        size:
          type: integer
          minimum: 0
          description: Members of the list now.
        campaign_ids:
          type: array
          items:
            type: string
            format: uuid
          description: The campaigns the list is associated with, oldest association first.
        archived:
          type: boolean
        archived_at:
          type:
            - string
            - "null"
          format: date-time
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    ImportMappingColumns:
      type: array
      minItems: 1
      maxItems: 500
      description: >
        ORDERED list of CSV column -> target field. The order is the file's,
        because

        a template is applied to a file whose header signature (its headers, in

        order) equals the template's.
      items:
        type: object
        required:
          - column
          - field
        properties:
          column:
            type: string
            maxLength: 200
            description: The CSV header, as the file spells it.
          field:
            type: string
            pattern: ^[a-z][a-z0-9_]{0,63}(\.[a-z][a-z0-9_]{0,63})?$
            description: The target field, or `skip`. One optional `group.name` dot
              (`contact.phone_e164`).
        additionalProperties: false
    ImportMappingOptions:
      type: object
      description: Parse options. Unknown keys are refused.
      properties:
        separator:
          type: string
          enum:
            - comma
            - semicolon
            - tab
          default: comma
        default_currency:
          type: string
          pattern: ^[A-Z]{3}$
      additionalProperties: false
    ImportMappingWrite:
      type: object
      required:
        - name
        - mapping
      properties:
        name:
          type: string
          maxLength: 200
        mapping:
          $ref: "#/components/schemas/ImportMappingColumns"
        options:
          $ref: "#/components/schemas/ImportMappingOptions"
      additionalProperties: false
    ImportMapping:
      type: object
      required:
        - id
        - name
        - archived
        - version
        - mapping
        - options
      description: "A template: its header and its CURRENT version."
      properties:
        id:
          type: string
          format: uuid
        tenant_id:
          type: string
          format: uuid
        name:
          type: string
        archived:
          type: boolean
        version:
          type: integer
          minimum: 1
          description: The current version.
        mapping:
          $ref: "#/components/schemas/ImportMappingColumns"
        options:
          $ref: "#/components/schemas/ImportMappingOptions"
        version_created_at:
          type: string
          format: date-time
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    ImportMappingVersion:
      type: object
      required:
        - mapping_id
        - version
        - mapping
        - options
      description: One immutable version of a template.
      properties:
        mapping_id:
          type: string
          format: uuid
        version:
          type: integer
          minimum: 1
        mapping:
          $ref: "#/components/schemas/ImportMappingColumns"
        options:
          $ref: "#/components/schemas/ImportMappingOptions"
        created_at:
          type: string
          format: date-time
    ImportMappingUse:
      type: object
      required:
        - id
        - mapping_id
        - version
        - imported
        - failed
      description: An import that used a version of a template.
      properties:
        id:
          type: string
          format: uuid
        mapping_id:
          type: string
          format: uuid
        version:
          type: integer
          minimum: 1
        list_id:
          oneOf:
            - type: string
              format: uuid
            - type: "null"
          description: The list, when the import went through `/v1/debt-lists/{id}/import`.
        imported:
          type: integer
          minimum: 0
        failed:
          type: integer
          minimum: 0
          description: Number of items that failed.
        created_at:
          type: string
          format: date-time
    QmCriteria:
      type: array
      minItems: 1
      maxItems: 100
      description: |
        ORDERED list of scoring criteria. `key` is unique within the form; the
        score of an evaluation is computed from these, in basis points.
      items:
        $ref: "#/components/schemas/QmCriterion"
    QmCriterion:
      type: object
      required:
        - key
        - label
        - kind
        - weight
      properties:
        key:
          type: string
          pattern: ^[A-Za-z0-9_-]{1,64}$
        label:
          type: string
          maxLength: 200
          description: Trimmed, not blank.
        kind:
          type: string
          enum:
            - yes_no
            - scale
        weight:
          type: integer
          minimum: 1
          maximum: 100
        max:
          type: integer
          minimum: 2
          maximum: 10
          description: Required for `scale`, refused for `yes_no`.
        critical:
          type: boolean
          default: false
          description: "`yes_no` only: a failed critical criterion fails the whole
            evaluation."
        na_allowed:
          type: boolean
          default: false
          description: The evaluator may mark the criterion not applicable.
      additionalProperties: false
    QmFormWrite:
      type: object
      required:
        - name
        - criteria
        - pass_threshold
      properties:
        name:
          type: string
          maxLength: 200
        criteria:
          $ref: "#/components/schemas/QmCriteria"
        pass_threshold:
          type: integer
          minimum: 0
          maximum: 100
      additionalProperties: false
    QmForm:
      type: object
      required:
        - id
        - name
        - archived
        - version
        - criteria
        - pass_threshold
      description: "A form: its header and its CURRENT version."
      properties:
        id:
          type: string
          format: uuid
        tenant_id:
          type: string
          format: uuid
        name:
          type: string
        archived:
          type: boolean
        version:
          type: integer
          minimum: 1
          description: The current version.
        criteria:
          $ref: "#/components/schemas/QmCriteria"
        pass_threshold:
          type: integer
          minimum: 0
          maximum: 100
        version_created_at:
          type: string
          format: date-time
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    QmFormVersion:
      type: object
      required:
        - form_id
        - version
        - criteria
        - pass_threshold
      description: One immutable version of a form.
      properties:
        form_id:
          type: string
          format: uuid
        version:
          type: integer
          minimum: 1
        criteria:
          $ref: "#/components/schemas/QmCriteria"
        pass_threshold:
          type: integer
          minimum: 0
          maximum: 100
        created_at:
          type: string
          format: date-time
    QmEvaluationWrite:
      type: object
      required:
        - form_id
        - form_version
        - answers
      properties:
        form_id:
          type: string
          format: uuid
          description: A live form of the tenant.
        form_version:
          type: integer
          minimum: 1
          description: The version the answers are scored against.
        answers:
          type: object
          description: >
            `criterion key -> answer`. `true`/`false` for `yes_no`, an integer
            from 0 to

            `max` for `scale`, the string `na` where `na_allowed`. One entry per
            criterion.
          additionalProperties: true
        comment:
          type: string
          maxLength: 2000
          description: Optional free text; blank reads as absent.
        supersedes_id:
          type: string
          format: uuid
          description: The CURRENT evaluation of this recording and form, to correct it.
      additionalProperties: false
    QmEvaluation:
      type: object
      required:
        - id
        - recording_id
        - call_attempt_id
        - agent_id
        - form_id
        - form_version
        - evaluator_user_id
        - evaluator_kind
        - answers
        - score_bp
        - auto_failed
        - passed
        - current
        - created_at
      description: >
        One frozen QM evaluation. Corrections are other rows that point here
        through `supersedes_id`.

        `evaluator_kind` is `human` (a person signed it, `evaluator_user_id` is
        that user) or

        `automatic` (the post-call AI pass over the form's criteria: nobody
        signed it and

        `evaluator_user_id` is `null`). The score is computed by core in both
        cases, never by

        the model. A human evaluation of the same recording and form replaces an
        automatic one

        through the same `supersedes_id` chain.
      properties:
        id:
          type: string
          format: uuid
        recording_id:
          type: string
          format: uuid
        call_attempt_id:
          type: string
          format: uuid
        agent_id:
          type: string
          format: uuid
        campaign_id:
          type:
            - string
            - "null"
          format: uuid
        form_id:
          type: string
          format: uuid
        form_version:
          type: integer
          minimum: 1
        evaluator_user_id:
          type:
            - string
            - "null"
          format: uuid
          description: The signing user; null when `evaluator_kind` is `automatic`.
        evaluator_kind:
          type: string
          enum:
            - human
            - automatic
        answers:
          type: object
          additionalProperties: true
        score_bp:
          type: integer
          minimum: 0
          maximum: 10000
          description: "Basis points: 8750 is 87.5 %."
        auto_failed:
          type: boolean
          description: A critical criterion was answered no; the score is 0.
        passed:
          type: boolean
        supersedes_id:
          type:
            - string
            - "null"
          format: uuid
        current:
          type: boolean
          description: No other evaluation replaces this one.
        comment:
          type:
            - string
            - "null"
          description: Present only for an eligible evaluator.
        created_at:
          type: string
          format: date-time
    QmAgentDispute:
      type: object
      required:
        - status
        - reason
        - opened_at
        - resolved_at
        - adjusted_evaluation_id
      description: The state of the agent's dispute of one evaluation, folded from its
        thread.
      properties:
        status:
          type: string
          enum:
            - open
            - upheld
            - adjusted
        reason:
          type: string
          enum:
            - wrong_call
            - criterion_misjudged
            - other
        opened_at:
          type: string
          format: date-time
        resolved_at:
          type:
            - string
            - "null"
          format: date-time
        adjusted_evaluation_id:
          type:
            - string
            - "null"
          format: uuid
          description: The evaluation that replaced this one when the dispute was
            `adjusted`.
    QmAgentEvaluation:
      type: object
      required:
        - id
        - recording_id
        - call_attempt_id
        - form_id
        - form_version
        - answers
        - score_bp
        - auto_failed
        - passed
        - comment
        - current
        - dispute
        - dispute_until
        - created_at
      description: >
        One QM evaluation as the evaluated agent sees it. Unlike `QmEvaluation`
        it has no

        `agent_id`, `evaluator_user_id` or `evaluator_kind`: the agent does not
        learn who

        evaluated in v1.
      properties:
        id:
          type: string
          format: uuid
        recording_id:
          type: string
          format: uuid
        call_attempt_id:
          type: string
          format: uuid
        campaign_id:
          type:
            - string
            - "null"
          format: uuid
        form_id:
          type: string
          format: uuid
        form_version:
          type: integer
          minimum: 1
        answers:
          type: object
          additionalProperties: true
        score_bp:
          type: integer
          minimum: 0
          maximum: 10000
          description: "Basis points: 8750 is 87.5 %."
        auto_failed:
          type: boolean
          description: A critical criterion was answered no; the score is 0.
        passed:
          type: boolean
        comment:
          type:
            - string
            - "null"
          description: The evaluator's free text; the evaluated agent can read it.
        supersedes_id:
          type:
            - string
            - "null"
          format: uuid
        current:
          type: boolean
          description: No other evaluation replaces this one.
        dispute:
          oneOf:
            - $ref: "#/components/schemas/QmAgentDispute"
            - type: "null"
        dispute_until:
          type:
            - string
            - "null"
          format: date-time
          description: The end of the dispute window while the dispute can still be
            opened; null otherwise.
        created_at:
          type: string
          format: date-time
    QmAgentDisputeEvent:
      type: object
      required:
        - id
        - kind
        - reason
        - note
        - adjusted_evaluation_id
        - created_at
      description: One event of the dispute thread. No actor ids.
      properties:
        id:
          type: string
          format: uuid
        kind:
          type: string
          enum:
            - opened
            - upheld
            - adjusted
        reason:
          type:
            - string
            - "null"
          enum:
            - wrong_call
            - criterion_misjudged
            - other
            - null
          description: Set on the opening.
        note:
          type:
            - string
            - "null"
          description: Optional free text of the event.
        adjusted_evaluation_id:
          type:
            - string
            - "null"
          format: uuid
        created_at:
          type: string
          format: date-time
    QmAgentEvaluationDetail:
      description: A `QmAgentEvaluation` plus the criteria it was scored against and
        its dispute thread.
      allOf:
        - $ref: "#/components/schemas/QmAgentEvaluation"
        - type: object
          required:
            - criteria
            - pass_threshold
            - dispute_events
          properties:
            criteria:
              oneOf:
                - $ref: "#/components/schemas/QmCriteria"
                - type: "null"
            pass_threshold:
              type:
                - integer
                - "null"
              minimum: 0
              maximum: 100
            dispute_events:
              type: array
              items:
                $ref: "#/components/schemas/QmAgentDisputeEvent"
    QmDisputeOpen:
      type: object
      required:
        - reason
      description: The body that opens the dispute of an own evaluation.
      properties:
        reason:
          type: string
          enum:
            - wrong_call
            - criterion_misjudged
            - other
        note:
          type: string
          maxLength: 2000
          description: Optional free text; blank reads as absent.
      additionalProperties: false
    QmDisputeResolve:
      type: object
      required:
        - decision
      description: >
        The body that resolves a dispute. `answers` is required with `adjusted`
        and

        refused with `upheld`.
      properties:
        decision:
          type: string
          enum:
            - upheld
            - adjusted
        note:
          type: string
          maxLength: 2000
          description: Optional free text; blank reads as absent.
        answers:
          type: object
          description: >
            One answer per criterion of the form version of the disputed
            evaluation: a

            boolean for `yes_no`, an integer from 0 to `max` for `scale`, the
            string `na`

            where the criterion allows it.
          additionalProperties: true
        comment:
          type: string
          maxLength: 2000
          description: Optional free text of the new evaluation.
      additionalProperties: false
    QmDisputeResolution:
      type: object
      required:
        - id
        - evaluation_id
        - kind
        - note
        - adjusted_evaluation_id
        - created_at
      description: The event a resolution appended to the dispute thread.
      properties:
        id:
          type: string
          format: uuid
        evaluation_id:
          type: string
          format: uuid
          description: The disputed evaluation.
        kind:
          type: string
          enum:
            - upheld
            - adjusted
        note:
          type:
            - string
            - "null"
        adjusted_evaluation_id:
          type:
            - string
            - "null"
          format: uuid
          description: The evaluation that replaces the disputed one; null when upheld.
        created_at:
          type: string
          format: date-time
    QmAgentReportRow:
      type: object
      required:
        - agent_id
        - form_id
        - evaluator_kind
        - evaluations
        - avg_score_bp
        - pass_rate_bp
        - passed
        - auto_failed
        - adjusted
        - open_disputes
      description: The figures of one agent on one form over the window. Counts and
        basis points only.
      properties:
        agent_id:
          type: string
          format: uuid
        form_id:
          type:
            - string
            - "null"
          format: uuid
        evaluator_kind:
          type:
            - string
            - "null"
          enum:
            - human
            - automatic
            - null
          description: "Human and automatic evaluations are never averaged together: each
            has its own row. Null only on the empty row of an entity asked for
            without an `evaluator_kind` filter."
        evaluations:
          type: integer
          minimum: 0
          description: The current evaluations counted.
        avg_score_bp:
          type:
            - integer
            - "null"
          minimum: 0
          maximum: 10000
          description: Mean score in basis points, rounded; null when there is no
            evaluation.
        pass_rate_bp:
          type:
            - integer
            - "null"
          minimum: 0
          maximum: 10000
          description: Passed over evaluations in basis points; null when there is no
            evaluation.
        passed:
          type: integer
          minimum: 0
        auto_failed:
          type: integer
          minimum: 0
          description: Voided by a critical criterion.
        adjusted:
          type: integer
          minimum: 0
          description: Current evaluations that replaced a disputed one (the dispute was
            resolved as adjusted).
        open_disputes:
          type: integer
          minimum: 0
          description: Current evaluations with a dispute opened and not yet resolved.
            They keep counting in every other figure.
    QmCampaignReportRow:
      type: object
      required:
        - campaign_id
        - form_id
        - evaluator_kind
        - evaluations
        - avg_score_bp
        - pass_rate_bp
        - passed
        - auto_failed
        - adjusted
        - open_disputes
      description: The figures of one campaign on one form over the window. Counts and
        basis points only.
      properties:
        campaign_id:
          type:
            - string
            - "null"
          format: uuid
          description: null for recordings without a campaign.
        form_id:
          type:
            - string
            - "null"
          format: uuid
        evaluator_kind:
          type:
            - string
            - "null"
          enum:
            - human
            - automatic
            - null
          description: "Human and automatic evaluations are never averaged together: each
            has its own row. Null only on the empty row of an entity asked for
            without an `evaluator_kind` filter."
        evaluations:
          type: integer
          minimum: 0
          description: The current evaluations counted.
        avg_score_bp:
          type:
            - integer
            - "null"
          minimum: 0
          maximum: 10000
          description: Mean score in basis points, rounded; null when there is no
            evaluation.
        pass_rate_bp:
          type:
            - integer
            - "null"
          minimum: 0
          maximum: 10000
          description: Passed over evaluations in basis points; null when there is no
            evaluation.
        passed:
          type: integer
          minimum: 0
        auto_failed:
          type: integer
          minimum: 0
          description: Voided by a critical criterion.
        adjusted:
          type: integer
          minimum: 0
          description: Current evaluations that replaced a disputed one (the dispute was
            resolved as adjusted).
        open_disputes:
          type: integer
          minimum: 0
          description: Current evaluations with a dispute opened and not yet resolved.
            They keep counting in every other figure.
    InsightsMetric:
      type: string
      enum:
        - contact_rate
        - abandon_rate
        - cost_per_contact
        - gate_block_rate
        - media_timeout_rate
      description: "`contact_rate` alerts downwards; every other metric alerts upwards."
    InsightsAnomaly:
      type: object
      required:
        - id
        - campaign_id
        - metric
        - gate
        - rule
        - direction
        - state
        - window_start
        - window_end
        - observed
        - baseline_mean
        - baseline_stddev
        - stddev_floor
        - threshold
        - k
        - sample_n
        - baseline_n
        - series
        - opened_at
        - resolution
        - read
        - acknowledged_at
      properties:
        id:
          type: string
          format: uuid
        campaign_id:
          type:
            - string
            - "null"
          format: uuid
          description: "`null` for the whole-tenant series."
        metric:
          $ref: "#/components/schemas/InsightsMetric"
        gate:
          type:
            - string
            - "null"
          description: Only `gate_block_rate` carries a gate.
        rule:
          type: string
          description: The rule that produced it, e.g. `zscore.v1`.
        direction:
          type: string
          enum:
            - up
            - down
          description: The harmful direction that was crossed.
        state:
          type: string
          enum:
            - open
            - resolved
        window_start:
          type: string
          format: date-time
          description: The hourly bucket that crossed the threshold (inclusive).
        window_end:
          type: string
          format: date-time
          description: The same bucket (exclusive).
        observed:
          type: number
          description: The bucket value.
        baseline_mean:
          type: number
        baseline_stddev:
          type: number
          description: Sample standard deviation of the baseline buckets.
        stddev_floor:
          type: number
          description: The floor applied to the deviation so a near-constant series does
            not alert on noise.
        threshold:
          type: number
          description: "`baseline_mean ± k·max(baseline_stddev, stddev_floor)` on the
            harmful side."
        k:
          type: number
          description: The sensitivity in force when it was opened (`insights_anomaly_k`).
        sample_n:
          type: integer
          minimum: 30
          description: Attempts in the bucket.
        baseline_n:
          type: integer
          minimum: 24
          description: Baseline buckets used.
        series:
          type: array
          description: The baseline buckets followed by the bucket that crossed, oldest
            first.
          items:
            $ref: "#/components/schemas/InsightsSeriesPoint"
        opened_at:
          type: string
          format: date-time
        resolution:
          type:
            - object
            - "null"
          description: The row that closed it; `null` while open.
          required:
            - id
            - window_start
            - window_end
            - observed
            - resolved_at
          properties:
            id:
              type: string
              format: uuid
            window_start:
              type: string
              format: date-time
            window_end:
              type: string
              format: date-time
            observed:
              type: number
            resolved_at:
              type: string
              format: date-time
        read:
          type:
            - boolean
            - "null"
          description: Whether the calling user acknowledged it; `null` for a `dd_` key.
        acknowledged_at:
          type:
            - string
            - "null"
          format: date-time
    InsightsSeriesPoint:
      type: object
      required:
        - t
        - sample
        - numerator
        - denominator
        - value
      properties:
        t:
          type: string
          format: date-time
          description: Start of the hourly bucket.
        sample:
          type: integer
          minimum: 0
        numerator:
          type: number
        denominator:
          type: number
        value:
          type:
            - number
            - "null"
          description: "`numerator / denominator`."
    InsightsBand:
      type: object
      required:
        - mean
        - stddev
        - stddev_floor
        - lower
        - upper
        - baseline_n
      properties:
        mean:
          type: number
        stddev:
          type: number
        stddev_floor:
          type: number
        lower:
          type: number
          description: "`mean - k·max(stddev, stddev_floor)`."
        upper:
          type: number
          description: "`mean + k·max(stddev, stddev_floor)`."
        baseline_n:
          type: integer
          minimum: 24
    InsightsSeriesBucket:
      type: object
      required:
        - start
        - sample
        - numerator
        - denominator
        - value
        - band
      properties:
        start:
          type: string
          format: date-time
        sample:
          type: integer
          minimum: 0
        numerator:
          type: number
        denominator:
          type: number
        value:
          type:
            - number
            - "null"
        band:
          oneOf:
            - $ref: "#/components/schemas/InsightsBand"
            - type: "null"
          description: "`null` while the bucket has fewer than `min_baseline` baseline
            buckets."
    InsightsSeries:
      type: object
      required:
        - rule
        - metric
        - campaign_id
        - gate
        - direction
        - k
        - min_sample
        - min_baseline
        - from
        - to
        - buckets
      properties:
        rule:
          type: string
        metric:
          $ref: "#/components/schemas/InsightsMetric"
        campaign_id:
          type:
            - string
            - "null"
          format: uuid
        gate:
          type:
            - string
            - "null"
        direction:
          type: string
          enum:
            - up
            - down
        k:
          type: number
        min_sample:
          type: integer
          description: Attempts a bucket needs to be evaluated.
        min_baseline:
          type: integer
          description: Baseline buckets a series needs to be evaluated.
        from:
          type: string
          format: date-time
        to:
          type: string
          format: date-time
        buckets:
          type: array
          items:
            $ref: "#/components/schemas/InsightsSeriesBucket"
    InsightsAck:
      type: object
      required:
        - anomaly_id
        - user_id
        - acknowledged_at
      properties:
        anomaly_id:
          type: string
          format: uuid
        user_id:
          type: string
          format: uuid
        acknowledged_at:
          type: string
          format: date-time
    DisclosureScriptWrite:
      type: object
      required:
        - jurisdiction
        - debt_type
        - text
        - legal_ref
      properties:
        kind:
          type: string
          enum:
            - collection_notice
          default: collection_notice
        jurisdiction:
          type: string
          description: A USPS state code (upper case) or `*` for any.
        debt_type:
          type: string
          enum:
            - other
            - student_loan
            - "*"
          description: "`*` for any."
        text:
          type: string
          maxLength: 10000
          description: At most 10 000 BYTES; not blank.
        legal_ref:
          type: string
          description: The citation of the rule or opinion the text rests on; not blank.
      additionalProperties: false
    DisclosureScriptEvent:
      type: object
      required:
        - id
        - actor
        - occurred_at
      properties:
        id:
          type: string
          format: uuid
        actor:
          type: string
          description: "Who wrote the event: the dashboard user id, or `tenant-api-key`
            for a `dd_` key."
        reviewed_by:
          type:
            - string
            - "null"
          description: The person or firm that reviewed the text.
        occurred_at:
          type: string
          format: date-time
          description: Server clock, never the client's.
    DisclosureScript:
      type: object
      required:
        - id
        - tenant_id
        - kind
        - jurisdiction
        - debt_type
        - version
        - text
        - text_sha256
        - legal_ref
        - authored_by
        - status
      description: One immutable version of a collection-notice script and its status.
      properties:
        id:
          type: string
          format: uuid
        tenant_id:
          type: string
          format: uuid
        kind:
          type: string
          enum:
            - collection_notice
        jurisdiction:
          type: string
        debt_type:
          type: string
        version:
          type: integer
          minimum: 1
          description: Per tenant and selector.
        text:
          type: string
        text_sha256:
          type: string
          pattern: ^[0-9a-f]{64}$
          description: SHA-256 (lower hex) of `text`, derived by core.
        legal_ref:
          type: string
        authored_by:
          type: string
        status:
          type: string
          enum:
            - draft
            - approved
            - retired
          description: Read from the sealed events; no `approved` event means `draft`.
        approval:
          oneOf:
            - $ref: "#/components/schemas/DisclosureScriptEvent"
            - type: "null"
        retirement:
          oneOf:
            - $ref: "#/components/schemas/DisclosureScriptEvent"
            - type: "null"
        created_at:
          type: string
          format: date-time
    SegmentCriteria:
      type: object
      description: >
        Criteria accepted by the segment-filter writes and `to-list` routes

        (`Dialer.Debts.Segment`, ADR #221). A closed vocabulary, combined with
        AND;

        an empty object is the whole portfolio. `state`, `debt_type`, `us_state`
        and

        `regf_status` take one value or a list (they are always returned as a
        sorted

        list without repeats). `phone`, `consumer_ref`, `external_ref` and

        `debtor_name` are refused by name.
      properties:
        campaign_id:
          type: string
          format: uuid
        state:
          oneOf:
            - type: string
              enum:
                - open
                - in_collection
                - promise_to_pay
                - paid
                - settled
                - disputed
                - closed
            - type: array
              minItems: 1
              maxItems: 100
              items:
                type: string
                enum:
                  - open
                  - in_collection
                  - promise_to_pay
                  - paid
                  - settled
                  - disputed
                  - closed
        debt_type:
          oneOf:
            - type: string
              enum:
                - other
                - student_loan
            - type: array
              minItems: 1
              maxItems: 100
              items:
                type: string
                enum:
                  - other
                  - student_loan
        us_state:
          oneOf:
            - type: string
              pattern: ^[A-Z]{2}$
            - type: array
              minItems: 1
              maxItems: 100
              items:
                type: string
                pattern: ^[A-Z]{2}$
        regf_status:
          description: "ADR #221 criterion: current Reg F eligibility derived by the
            server."
          oneOf:
            - type: string
              enum:
                - ok
                - blocked_7in7
                - blocked_post_contact
                - undecidable
            - type: array
              minItems: 1
              maxItems: 100
              items:
                type: string
                enum:
                  - ok
                  - blocked_7in7
                  - blocked_post_contact
                  - undecidable
        min_amount_cents:
          type: integer
          minimum: 0
        max_amount_cents:
          type: integer
          minimum: 0
        min_age_days:
          type: integer
          minimum: 0
          maximum: 36500
          description: "ADR #221 criterion: delinquent for at least this many days
            (debts.delinquent_since against today); a debt with no
            delinquent_since never matches."
        max_age_days:
          type: integer
          minimum: 0
          maximum: 36500
          description: "ADR #221 criterion: delinquent for at most this many days
            (debts.delinquent_since against today); a debt with no
            delinquent_since never matches. A min_age_days above max_age_days is
            refused."
      additionalProperties: false
    SegmentFilterWrite:
      type: object
      required:
        - name
        - criteria
      properties:
        name:
          type: string
          maxLength: 200
        criteria:
          $ref: "#/components/schemas/SegmentCriteria"
      additionalProperties: false
    SegmentToListWrite:
      type: object
      description: Exactly one of `list_id` and `new_list_name`.
      properties:
        list_id:
          type: string
          format: uuid
        new_list_name:
          type: string
          maxLength: 200
      additionalProperties: false
    SegmentToListInlineWrite:
      type: object
      required:
        - criteria
      description: Exactly one of `list_id` and `new_list_name`.
      properties:
        criteria:
          $ref: "#/components/schemas/SegmentCriteria"
        list_id:
          type: string
          format: uuid
        new_list_name:
          type: string
          maxLength: 200
      additionalProperties: false
    SegmentMaterialization:
      type: object
      required:
        - id
        - list_id
        - filter_id
        - filter_version
        - criteria
        - max_rows
        - matched
        - added
        - truncated
      description: >
        The evidence row of one photo (append-only). `matched` debts were
        selected

        (at most `max_rows`), `added` of them were NEW in the list and
        `truncated` says

        the catalog cap cut the result.
      properties:
        id:
          type: string
          format: uuid
        tenant_id:
          type: string
          format: uuid
        list_id:
          type: string
          format: uuid
        filter_id:
          oneOf:
            - type: string
              format: uuid
            - type: "null"
          description: The saved filter, or `null` when the criteria came inline.
        filter_version:
          oneOf:
            - type: integer
              minimum: 1
            - type: "null"
          description: The version of the filter that was used, or `null` when inline.
        criteria:
          allOf:
            - $ref: "#/components/schemas/SegmentCriteria"
          description: The criteria that were evaluated, in canonical form.
        max_rows:
          type: integer
          minimum: 1
          description: The cap in force for this photo.
        matched:
          type: integer
          minimum: 0
        added:
          type: integer
          minimum: 0
        truncated:
          type: boolean
        requested_at:
          type: string
          format: date-time
    SegmentMaterializationResult:
      type: object
      required:
        - segment_materialization
        - debt_list
      properties:
        segment_materialization:
          $ref: "#/components/schemas/SegmentMaterialization"
        debt_list:
          allOf:
            - $ref: "#/components/schemas/DebtList"
          description: The target list, its size counted after the photo.
    SegmentFilter:
      type: object
      required:
        - id
        - name
        - archived
        - version
        - criteria
      description: "A saved filter: its header and its CURRENT version."
      properties:
        id:
          type: string
          format: uuid
        tenant_id:
          type: string
          format: uuid
        name:
          type: string
        archived:
          type: boolean
        version:
          type: integer
          minimum: 1
          description: The current version.
        criteria:
          allOf:
            - $ref: "#/components/schemas/SegmentCriteria"
          description: The criteria of the current version, in canonical form (lists
            sorted, without repeats).
        version_created_at:
          type: string
          format: date-time
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    Debt:
      type: object
      properties:
        id:
          type: string
          format: uuid
        tenant_id:
          type: string
          format: uuid
        campaign_id:
          oneOf:
            - type: string
              format: uuid
            - type: "null"
          description: >
            The debt's DIRECT slot: the campaign it was imported or assigned to.

            It is not the whole answer to "which campaigns work this debt": a

            debt is also in the population of every campaign a list holding it
            is

            associated with (`/v1/debt-lists`).
        external_ref:
          type: string
        consumer_ref:
          type: string
        debt_type:
          type: string
          enum:
            - other
            - student_loan
        account_number:
          type: string
        debtor_name:
          oneOf:
            - type: string
              maxLength: 200
            - type: "null"
          description: >
            The debtor's name as the CMS sent it on import (core#1529); `null`
            when it

            sent none. Encrypted at rest. Served by `GET /v1/debts/{id}`, the
            debtor

            card and the agent's call card; NOT by the `GET /v1/debts` listing,
            the

            webhooks or the analytics export.
        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
    DisclosureAttestation:
      type: object
      description: >
        What an agent declares about the mandatory collection notice on one
        call.

        Strict: a key outside this list is a `400`.
      additionalProperties: false
      required:
        - outcome
      properties:
        outcome:
          type: string
          enum:
            - delivered
            - not_delivered
          description: No default — it must be sent.
        reason:
          type: string
          enum:
            - consumer_hung_up
            - third_party_on_line
            - consumer_declined
            - other
          description: Required with `not_delivered`, refused with `delivered`.
        note:
          type: string
          description: Only with `reason` `other`. Stored encrypted.
        script_sha256:
          type: string
          pattern: ^[0-9a-f]{64}$
          description: >
            Lowercase hex sha256 of the notice text the agent read. Optional.
            With `script_version_id`

            it must equal that version's digest (otherwise `422
            disclosure_script_mismatch`).
        script_version_id:
          type: string
          format: uuid
          description: >
            The version of the tenant's script library the agent read
            (core#1690). Optional; when sent,

            core validates it and derives `script_sha256` and
            `script_resolution` from it.
        supersedes_id:
          type: string
          format: uuid
          description: Rectifies the CURRENT attestation of the same call; omit it for the
            first.
    DisclosureEvent:
      type: object
      description: >
        One sealed, append-only attestation. It states what the agent DECLARED:

        `evidence_kind` is `agent_attestation`, never proof that the notice was
        said

        or heard.
      properties:
        id:
          type: string
          format: uuid
        tenant_id:
          type: string
          format: uuid
        call_attempt_id:
          type: string
          format: uuid
        debt_id:
          type: string
          format: uuid
        agent_id:
          type: string
          format: uuid
        evidence_kind:
          type: string
          enum:
            - agent_attestation
        outcome:
          type: string
          enum:
            - delivered
            - not_delivered
        reason:
          oneOf:
            - type: string
            - type: "null"
        note:
          oneOf:
            - type: string
            - type: "null"
        script_sha256:
          oneOf:
            - type: string
            - type: "null"
        script_version_id:
          type: string
          format: uuid
          description: Present only when the attestation is linked to a script library
            version (core#1690).
        script_resolution:
          type: string
          enum:
            - exact
            - state
            - debt_type
            - tenant_default
          description: Present only with `script_version_id`; the precedence branch the
            version's selector belongs to for this call.
        supersedes_id:
          oneOf:
            - type: string
              format: uuid
            - type: "null"
        occurred_at:
          type: string
          format: date-time
        created_at:
          type: string
          format: date-time
    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|delivered|ai_conversation|answered_machine|ai_stream_lost|busy|no_answer|canceled|rejected|failed`; typed agent/AI outcomes are free-form (e.g. `promise_to_pay`).
            An answered call that was never bridged is named by what the answer was (core#1323): `answered_machine` on an AMD machine verdict,
            `ai_conversation` for a voice-AI campaign, `delivered` for a TTS/IVR/survey campaign, and `abandoned` ONLY when an agent was
            expected (the TSR/FCC abandoned call). Rows written before that release keep `abandoned` for all four.
            A voice-AI call whose session voice-AI refused because it was full, and that no agent took, is also `abandoned` (core#1326):
            the person answered and nobody spoke to them.
        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
        disclosure:
          $ref: "#/components/schemas/CallAttemptDisclosure"
    CallAttemptDisclosure:
      type: object
      description: >
        What the agent DECLARED about the mandatory collection notice on this
        call

        (core#1587, ADR #211). On `GET /v1/call_attempts` and the defense packet
        v2 rows; every row of

        an attempt (the original and its corrections) carries the attempt's
        state.

        It is a declaration, never proof that the notice was said or heard:

        there is no "verified" nor "compliant" state.


        `attested` — the current attestation says `delivered`.
        `declared_not_delivered`

        — it says `not_delivered` (the reason is on the event). `unattested` —
        the call

        is IN SCOPE (it has a debt and the pipeline finalised it
        `answered_human` or

        `transferred`) and nobody attested. `not_applicable` — out of scope (no
        debt, or

        not a human contact). `undecidable` — the call has no final row yet, so
        scope

        cannot be decided. Scope never depends on the code the agent typed.
      required:
        - status
        - event_id
        - at
        - revised
      properties:
        status:
          type: string
          enum:
            - attested
            - declared_not_delivered
            - unattested
            - not_applicable
            - undecidable
        event_id:
          oneOf:
            - type: string
              format: uuid
            - type: "null"
          description: The current attestation (`null` when there is none).
        at:
          oneOf:
            - type: string
              format: date-time
            - type: "null"
          description: Server time of the current attestation.
        revised:
          type: boolean
          description: "`true` when the attempt has more than one attestation (one
            rectifies another)."
        script:
          description: >
            Only on `GET /v1/call_attempts` (core#1690, ADR #219 Decisions 3-5);
            the defense packet v2

            rows do not carry it. The approved script that applies to this call,
            resolved

            server-side from the facts frozen in the attempt (consumer state and
            debt type), so

            the station computes nothing. Only versions approved before the call
            ended (or approved

            now, while it has not ended) and not retired today take part, so the
            version shown is one

            the attestation accepts. `null` when the tenant has no such version,
            when the

            attempt has no frozen facts, or when no version applies. It is not
            the version the agent

            attested: that one is sealed on the attestation event.
          oneOf:
            - $ref: "#/components/schemas/CallAttemptDisclosureScript"
            - type: "null"
        script_match:
          type: string
          enum:
            - matched
            - other_version
            - unscripted
            - not_applicable
          description: >
            Only on `GET /v1/call_attempts` (core#1691, ADR #219 Decision 7).
            Compares the script version

            the current attestation NAMES with the one `script` publishes now.
            `matched` — the same.

            `other_version` — it names another one (for example one replaced
            after the call started, which

            stays valid) or none is published any more. `unscripted` — there is
            an attestation that

            names no version and a script did apply. `not_applicable` — there is
            no attestation, or no

            script to compare. A reading mark: it blocks nothing and does not
            say that any text complies

            with any rule.
    CallAttemptDisclosureScript:
      type: object
      description: "The approved script version resolved for a call (core#1690, ADR
        #219 Decision 4)."
      required:
        - version_id
        - version
        - text
        - text_sha256
        - resolution
      properties:
        version_id:
          type: string
          format: uuid
        version:
          type: integer
          description: Version number inside its selector.
        text:
          type: string
          description: The script to read aloud.
        text_sha256:
          type: string
          description: SHA-256 (hex) of `text`; the value to send back as `script_sha256`
            when attesting.
        resolution:
          type: string
          enum:
            - exact
            - state
            - debt_type
            - tenant_default
          description: Which rung of the precedence ladder picked the version.
    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.
    CallDetailRecordPage:
      type: object
      description: |
        Canonical protojson of a page of `dialer.v1.CallDetailRecord`. Fields at
        their proto3 default are omitted, so `nextCursor` is absent on the last
        page and an empty page is `{}`.
      additionalProperties: false
      properties:
        callDetailRecords:
          type: array
          items:
            $ref: "#/components/schemas/CallDetailRecord"
        nextCursor:
          type: string
          description: Opaque keyset cursor; absent on the last page.
    CallDetailRecord:
      type: object
      description: >
        `dialer.v1.CallDetailRecord` at the pinned `contracts` tag, canonical

        protojson. Every property is optional because a field at its proto3

        default is omitted. See `GET /v1/reports/cdr` for which fields this
        surface

        fills.
      additionalProperties: false
      properties:
        callId:
          type: string
        tenantId:
          type: string
        debtId:
          type: string
        consumerId:
          type: string
        campaignId:
          type: string
        direction:
          $ref: "#/components/schemas/CdrDirection"
        fromNumber:
          type: string
        toNumber:
          type: string
        startedAt:
          type: string
          format: date-time
        answeredAt:
          type: string
          format: date-time
        endedAt:
          type: string
          format: date-time
        durationSeconds:
          type: integer
          format: uint32
        billsec:
          type: integer
          format: uint32
        disposition:
          $ref: "#/components/schemas/CdrDisposition"
        hangupCause:
          type: string
        sipResponseCode:
          type: integer
          format: uint32
        recordingUri:
          type: string
        aiUsed:
          type: boolean
        mediaPath:
          $ref: "#/components/schemas/CdrMediaPath"
        carrier:
          type: string
        carrierTrunkId:
          type: string
        attestation:
          $ref: "#/components/schemas/CdrAttestation"
        costWeight:
          type: number
          format: double
        cell:
          type: string
        fsNode:
          type: string
        sipEdgeNode:
          type: string
        agentId:
          type: string
        complianceDecisionId:
          type: string
        recordVersion:
          type: integer
          format: uint32
        labels:
          type: object
          additionalProperties:
            type: string
          description: "`disposition_code` carries a REST disposition the `Disposition`
            enum does not list."
        originationSource:
          $ref: "#/components/schemas/CdrOriginationSource"
        transferredToCallId:
          type: string
    CdrDirection:
      type: string
      enum:
        - DIRECTION_UNSPECIFIED
        - DIRECTION_OUTBOUND
        - DIRECTION_INBOUND
        - DIRECTION_INTERNAL
    CdrDisposition:
      type: string
      enum:
        - DISPOSITION_UNSPECIFIED
        - DISPOSITION_ANSWERED_HUMAN
        - DISPOSITION_ANSWERED_MACHINE
        - DISPOSITION_BUSY
        - DISPOSITION_NO_ANSWER
        - DISPOSITION_FAILED
        - DISPOSITION_REJECTED
        - DISPOSITION_CANCELED
        - DISPOSITION_BLOCKED_COMPLIANCE
        - DISPOSITION_VOICEMAIL_DROP
        - DISPOSITION_TRANSFERRED
    CdrMediaPath:
      type: string
      enum:
        - MEDIA_PATH_UNSPECIFIED
        - MEDIA_PATH_RTPENGINE_KERNEL
        - MEDIA_PATH_FREESWITCH
    CdrOriginationSource:
      type: string
      enum:
        - ORIGINATION_SOURCE_UNSPECIFIED
        - ORIGINATION_SOURCE_CAMPAIGN_AUTO
        - ORIGINATION_SOURCE_AGENT_PREVIEW
        - ORIGINATION_SOURCE_AGENT_MANUAL
        - ORIGINATION_SOURCE_API_CTI
        - ORIGINATION_SOURCE_AI
        - ORIGINATION_SOURCE_WEB_REQUEST
        - ORIGINATION_SOURCE_API_CALL
    CdrAttestation:
      type: string
      enum:
        - ATTESTATION_UNSPECIFIED
        - ATTESTATION_A
        - ATTESTATION_B
        - ATTESTATION_C
        - ATTESTATION_NONE
    Call:
      description: >
        A call as `GET /v1/calls` and `GET /v1/calls/{call_id}` serve it: the

        `CallAttempt` row, plus — ONLY when the caller is an agent (`ddw_`)

        principal — the debtor's name (core#1529), which is what the widget
        shows

        during the call. A tenant (`dd_`/`ddu_`) principal never gets the key:
        it

        reads the name on the debt itself. The key is never part of the

        `call.answered` / `call.ended` webhook bodies, `GET /v1/call_attempts`,
        or

        the analytics export.
      allOf:
        - $ref: "#/components/schemas/CallAttempt"
        - type: object
          properties:
            debtor_name:
              oneOf:
                - type: string
                  maxLength: 200
                - type: "null"
              description: "`null` when the call has no debt or the debt has no name."
    TransferLeg:
      type: object
      description: >
        One leg of a transferred call (core#2403). `control` = the agent holds
        the

        call; `consult` = the agent is on the consultation leg of an attended

        transfer. Durations are the difference of two durable marks and are

        `null` while the leg is still open or was never answered — never
        invented.
      required:
        - seq
        - role
        - agent_id
        - destination
        - via
        - started_at
        - ended_at
        - duration_ms
      properties:
        seq:
          type: integer
          minimum: 1
        role:
          type: string
          enum:
            - control
            - consult
        agent_id:
          oneOf:
            - type: string
              format: uuid
            - type: "null"
          description: "`null` = control that left the agent population (external
            transfer, take-over) or a consultation recorded before the agent was
            stamped."
        destination:
          type: string
          description: The extension or number the verb addressed; empty for the origin leg.
        via:
          type: string
          description: "`origin` for the agent the call started with; otherwise the verb
            that opened the leg (`blind_transfer`, `attended_transfer_complete`,
            `attended_transfer_start`, ...)."
        started_at:
          oneOf:
            - type: string
              format: date-time
            - type: "null"
        ended_at:
          oneOf:
            - type: string
              format: date-time
            - type: "null"
        duration_ms:
          oneOf:
            - type: integer
              minimum: 0
            - type: "null"
    CallDetail:
      description: >
        `Call` plus `transfer_chain` (core#2403): the legs of the call in order,

        `[]` when nobody transferred it; and `recording` (core#2760): the mask
        of

        the live call's recording.
      allOf:
        - $ref: "#/components/schemas/Call"
        - type: object
          required:
            - transfer_chain
            - recording
          properties:
            recording:
              type: string
              enum:
                - on
                - paused
                - none
              description: >
                `paused` while the recording is live with a mask stretch open

                (the intent core holds, re-applied after a transfer) and no
                failed

                re-application after a transfer is the last word on it; `on`
                while

                it is live and unmasked, and also while the stretch is open but
                its

                re-application after a transfer FAILED (core#2786: the switch is

                recording; a new `recording/pause` re-sends the mask); `none`
                when

                the call has no live

                recording (no row, policy off, or audio already closed) — the
                same

                condition under which `recording/pause` answers 409
                `not_recording`.
            transfer_chain:
              type: array
              items:
                $ref: "#/components/schemas/TransferLeg"
    AgentHandleTimeResponse:
      type: object
      required:
        - definition
        - from
        - to
        - agents
        - excluded
      properties:
        definition:
          type: string
          enum:
            - v1
        from:
          type: string
          format: date-time
        to:
          type: string
          format: date-time
        agents:
          type: array
          items:
            type: object
            required:
              - agent_id
              - calls
              - control_ms
              - consult_ms
              - occupied_ms
              - aht_ms
            properties:
              agent_id:
                type: string
                format: uuid
              calls:
                type: integer
                minimum: 0
                description: Calls in which the agent held control for a measurable time.
              control_ms:
                type: integer
                minimum: 0
              consult_ms:
                type: integer
                minimum: 0
              occupied_ms:
                type: integer
                minimum: 0
              aht_ms:
                oneOf:
                  - type: integer
                    minimum: 0
                  - type: "null"
        excluded:
          type: object
          required:
            - open_legs
          properties:
            open_legs:
              type: integer
              minimum: 0
    CallPage:
      type: object
      required:
        - calls
        - next_cursor
      properties:
        calls:
          type: array
          items:
            $ref: "#/components/schemas/Call"
        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"
        note_redacted:
          type: boolean
          description: >
            Present and `true` only on the answer to the write that stored a
            note

            with a card number, CVV or PIN in it and the tenant key

            `note_pii_redact` is `enforce`: the sensitive data was masked before
            it

            was saved (`****-****-****-1234`, `[REDACTED]`).
        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"
        note_redacted:
          type: boolean
          description: >
            Present and `true` only on the answer to the write that stored a
            note

            with a card number, CVV or PIN in it and the tenant key

            `note_pii_redact` is `enforce`: the sensitive data was masked before
            it

            was saved (`****-****-****-1234`, `[REDACTED]`).
        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
    MeScorecard:
      type: object
      additionalProperties: false
      required:
        - agent
        - window
        - calls
        - promises
      properties:
        agent:
          type: object
          additionalProperties: false
          required:
            - id
            - name
            - sip_extension
          description: |
            The seat DERIVED from the bearer — echoed so the client can show
            whose marker it is, never to let the client choose it.
          properties:
            id:
              type: string
              format: uuid
            name:
              type: string
            sip_extension:
              oneOf:
                - type: string
                - type: "null"
        window:
          type: object
          additionalProperties: false
          required:
            - from
            - to
            - source
          properties:
            from:
              type: string
              format: date-time
            to:
              type: string
              format: date-time
            source:
              type: string
              enum:
                - checkin
                - explicit
                - fallback_12h
              description: |
                Where `from` came from: the seat's ACTIVE check-in (`checkin`,
                the shift), the client's own parameter (`explicit`), or the
                estimated `now - 12h` window used when the seat has no active
                check-in (`fallback_12h`, the normal state right after
                checkout). A client MUST show the estimated case as such.
        calls:
          type: object
          additionalProperties: false
          required:
            - total
            - in_flight
            - by_disposition
          description: |
            Counted per ATTEMPT (the original row plus every correction
            appended to it is ONE call), over attempts whose `started_at` is in
            the window and that are attributed to this seat — including the
            inbound attempt whose agent was named by a later assignment.
            Invariant: `total == in_flight + sum(by_disposition)`.
          properties:
            total:
              type: integer
              minimum: 0
            in_flight:
              type: integer
              minimum: 0
              description: |
                Attempts with no settling correction yet. Counted apart instead
                of being given a fabricated disposition.
            by_disposition:
              type: object
              description: |
                Attempt counts keyed by the disposition of the LAST settling
                correction written for the attempt.
              additionalProperties:
                type: integer
                minimum: 0
        promises:
          type: object
          additionalProperties: false
          required:
            - count
            - by_status
            - amount_cents
          description: |
            Promises this seat RECORDED in the window (`inserted_at`, not
            `promised_date`: a shift contains promises dated weeks ahead).
          properties:
            count:
              type: integer
              minimum: 0
            by_status:
              type: object
              additionalProperties: false
              required:
                - pending
                - kept
                - broken
                - cancelled
              properties:
                pending:
                  type: integer
                  minimum: 0
                kept:
                  type: integer
                  minimum: 0
                broken:
                  type: integer
                  minimum: 0
                cancelled:
                  type: integer
                  minimum: 0
            amount_cents:
              type: object
              additionalProperties: false
              required:
                - USD
                - MXN
              description: |
                Summed per currency — USD and MXN never sum together — and
                EXCLUDING `cancelled` promises: a cancellation is an operator
                withdrawal, so the row survives as evidence but the commitment
                does not. The cancelled ones are still visible in `by_status`.
              properties:
                USD:
                  type: integer
                  minimum: 0
                MXN:
                  type: integer
                  minimum: 0
    WfmCoverageResponse:
      type: object
      additionalProperties: false
      required:
        - date
        - interval_minutes
        - source
        - simulated
        - history
        - target
        - intervals
      properties:
        date:
          type: string
          format: date
        interval_minutes:
          type: integer
          enum:
            - 30
        source:
          type: string
          enum:
            - history
            - estimate
          description: "`history` when the window reached the sample floor; `estimate` is
            the flat default."
        simulated:
          type: boolean
          description: True when the target is the one in the query, not the tenant's.
        history:
          type: object
          additionalProperties: false
          required:
            - reference_days
            - offered_sample
            - min_reference_days
            - min_sample_calls
          properties:
            reference_days:
              type: array
              items:
                type: string
                format: date
              description: The past days (same weekday) the forecast was averaged over.
            offered_sample:
              type: integer
              minimum: 0
            min_reference_days:
              type: integer
            min_sample_calls:
              type: integer
        target:
          oneOf:
            - type: object
              additionalProperties: false
              required:
                - threshold_ms
                - target_percent
                - simulated
              properties:
                threshold_ms:
                  type: integer
                  minimum: 1
                target_percent:
                  type: integer
                  minimum: 1
                  maximum: 100
                simulated:
                  type: boolean
            - $ref: "#/components/schemas/WfmInert"
        intervals:
          type: array
          items:
            $ref: "#/components/schemas/WfmCoverageInterval"
    WfmInert:
      type: object
      additionalProperties: false
      required:
        - inert
      properties:
        inert:
          type: string
          enum:
            - threshold_unconfigured
            - threshold_malformed
            - target_unconfigured
            - target_malformed
          description: Why there is no service target; without one nothing is sized.
    WfmCoverageInterval:
      type: object
      additionalProperties: false
      required:
        - start
        - end
        - forecast
        - staffing
        - scheduled_agents
        - coverage
      properties:
        start:
          type: string
          format: date-time
        end:
          type: string
          format: date-time
        forecast:
          type: object
          additionalProperties: false
          required:
            - volume
            - aht_ms
            - aht_source
          properties:
            volume:
              type: number
              minimum: 0
              description: Expected offered calls in the interval.
            aht_ms:
              type: integer
              minimum: 1
            aht_source:
              type: string
              enum:
                - slot
                - pooled
                - estimate
              description: "`slot` from this interval's own history; `pooled` the window-wide
                AHT; `estimate` the default."
        staffing:
          oneOf:
            - type: object
              additionalProperties: false
              required:
                - required_agents
                - service_level
                - occupancy
                - reachable
              properties:
                required_agents:
                  type: integer
                  minimum: 0
                service_level:
                  type:
                    - number
                    - "null"
                  description: Expected share (0..1) answered within the threshold; null with no
                    volume or an unreachable load.
                occupancy:
                  type:
                    - number
                    - "null"
                  description: Offered erlangs / required agents; null with no volume or an
                    unreachable load.
                reachable:
                  type: boolean
                  description: False when the target needs more than the 5000-agent cap;
                    `required_agents` is then the cap.
            - $ref: "#/components/schemas/WfmInert"
        scheduled_agents:
          type: number
          minimum: 0
          description: Full-time equivalents planned in the interval (R11 shifts).
        coverage:
          type:
            - object
            - "null"
          additionalProperties: false
          required:
            - gap
            - ratio
          description: Null when the target is inert.
          properties:
            gap:
              type: number
              description: Planned minus required; negative is a shortfall.
            ratio:
              type:
                - number
                - "null"
              description: Planned / required; null when nothing is required.
    AgentOccupancyResponse:
      type: object
      additionalProperties: false
      required:
        - window
        - agents
      properties:
        window:
          type: object
          additionalProperties: false
          required:
            - from
            - to
          properties:
            from:
              type: string
              format: date-time
            to:
              type: string
              format: date-time
        agents:
          type: object
          description: Map keyed by agent UUID; seats without interval evidence are absent.
          propertyNames:
            format: uuid
          additionalProperties:
            $ref: "#/components/schemas/AgentOccupancyReport"
    AgentOccupancyReport:
      type: object
      additionalProperties: false
      required:
        - by_state
        - intervals
        - occupied_ms
        - counted_ms
        - excluded
        - overlapping_intervals
        - occupancy
      properties:
        by_state:
          type: object
          description: Counted monotonic milliseconds keyed by recorded agent state.
          additionalProperties:
            type: integer
            minimum: 0
        intervals:
          type: object
          description: Counted interval rows keyed by recorded agent state.
          additionalProperties:
            type: integer
            minimum: 0
        occupied_ms:
          type: integer
          minimum: 0
          description: Counted milliseconds whose state is `on_call`.
        counted_ms:
          type: integer
          minimum: 0
          description: Sum of all values in `by_state`.
        excluded:
          type: object
          additionalProperties: false
          required:
            - synthetic
            - open
          properties:
            synthetic:
              type: object
              additionalProperties: false
              required:
                - intervals
                - ms
              properties:
                intervals:
                  type: integer
                  minimum: 0
                ms:
                  type: integer
                  minimum: 0
            open:
              type: object
              additionalProperties: false
              required:
                - intervals
              properties:
                intervals:
                  type: integer
                  minimum: 0
        overlapping_intervals:
          type: integer
          minimum: 0
          description: Counted consecutive interval pairs that overlap for this seat.
        occupancy:
          $ref: "#/components/schemas/AgentOccupancyRatio"
    AgentOccupancyRatio:
      type: object
      additionalProperties: false
      required:
        - definition
        - numerator_ms
        - denominator_ms
        - percent
        - reason
        - complete
      description: |
        Occupancy ratio computed by core from `by_state` (ADR #101, core#1077).
        Definition `v1`: `numerator_ms = on_call + wrap_up`;
        `denominator_ms = available + ringing + on_call + wrap_up` (counted
        time minus `paused`). No threshold and no flag is attached to it.
      properties:
        definition:
          type: string
          enum:
            - v1
          description: Name of the formula. A new formula is a new value, never a new
            meaning for `v1`.
        numerator_ms:
          type: integer
          minimum: 0
          description: "`by_state.on_call + by_state.wrap_up`."
        denominator_ms:
          type: integer
          minimum: 0
          description: "`by_state.available + by_state.ringing + by_state.on_call +
            by_state.wrap_up`."
        percent:
          oneOf:
            - type: number
              minimum: 0
              maximum: 100
            - type: "null"
          description: |
            `numerator_ms * 100 / denominator_ms`, rounded to one decimal.
            `null` when `reason` is set.
        reason:
          oneOf:
            - type: string
              enum:
                - overlapping_intervals
                - no_staffed_time
            - type: "null"
          description: >
            Why `percent` is `null`: `overlapping_intervals` (the counted time
            is

            contaminated by an overlap; takes precedence) or `no_staffed_time`

            (the denominator is 0 — absence, not a zero). `null` when `percent`

            is a number.
        complete:
          type: boolean
          description: "`false` when open or synthetic intervals of the window were
            excluded (see `excluded`)."
    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).
        pause_reason:
          oneOf:
            - type: string
              enum:
                - manual
                - break
                - lunch
                - training
                - meeting
            - type: "null"
          description: Why the seat is paused; `null` unless `presence` is `paused`
            (core#2542).
        state_since:
          oneOf:
            - type: string
              format: date-time
            - type: "null"
          description: When the seat entered its current `presence` (RFC 3339); `null`
            when the agent has no seat (core#2542).
        pause_armed:
          type: boolean
          description: "A pause is armed for when the current call ends (`POST
            /v1/agents/{id}/pause` with `on_release: true`)."
        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."
        turn:
          $ref: "#/components/schemas/TurnIceServer"
    TurnIceServer:
      type: object
      required:
        - urls
        - username
        - credential
      description: |
        The cell's own TURN relay (coturn) for agents whose network blocks UDP
        (MT-CTI-16, core#1076). A WebRTC `RTCIceServer` entry: the softphone
        appends it to the STUN list it reads from `GET /v1/softphone/ice`.
        It rides THIS authenticated answer precisely because that endpoint is
        unauthenticated — a relay credential there would reach every browser.

        Ephemeral, coturn `use-auth-secret` scheme: `username` is
        `<unix expiry>:<agent id>` and `credential` is
        `Base64(HMAC-SHA1(shared secret, username))`; nothing is stored per
        agent. It expires at the SAME instant as the SIP password
        (`expires_at`), so the re-checkin that renews one renews both.
        Absent when the cell has no TURN configured (`SOFTPHONE_TURN_URLS` /
        `SOFTPHONE_TURN_SECRET` unset): the softphone then negotiates without a
        relay, as it did before this key existed.
      properties:
        urls:
          type: array
          minItems: 1
          items:
            type: string
          description: "`turn:` / `turns:` URLs (`:3478` UDP/TCP, `:5349` TLS)."
        username:
          type: string
          description: "`<unix expiry>:<agent id>`."
        credential:
          type: string
          description: Ephemeral; NEVER persisted.
    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),
            and one the principal may act as (ADR #135; a seated `ddu_` only its
            own seat, `403 forbidden`); 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.
    BlindTransferRequest:
      type: object
      required:
        - destination
      description: >
        Where the call goes. A roster `sip_extension` or an agent id (UUID) of
        an

        ACTIVE agent of the caller's own tenant, or an EXTERNAL PSTN number in

        strict E.164, or the id of a CURRENT registered external destination

        (`GET /v1/transfer/targets`), which core resolves to its E.164 — the

        roster is consulted first, so a tenant whose extensions happen to be 10

        digits long still transfers internally.
      properties:
        destination:
          type: string
          minLength: 1
          maxLength: 64
          description: Internal `sip_extension` / agent id, or an external destination in
            strict E.164 (`+<country><number>`, 8–15 digits) or by its
            registration id. The server TRIMS the value before validating it and
            requires it to be non-blank and at most 64 BYTES once trimmed —
            `minLength`/ `maxLength` here count CHARACTERS on the raw value, so
            a blank-only string satisfies them and still earns a `400`. A
            number-shaped value that is NOT E.164 (bare national digits, a
            malformed `+` string) is refused `422` `destination_not_supported`.
          example: "1001"
    ConferenceAddRequest:
      type: object
      required:
        - destination
      description: |
        Who joins the call. `destination` is validated exactly like the blind
        transfer's. `consumer_agreed` is the agent's attestation that the
        consumer accepts an EXTERNAL third party hearing the call: it is
        required (`true`) when the destination is an external E.164 and not
        required for a roster colleague (if sent `true` there, it is still
        recorded on the audit row and on the colleague leg's row). A
        non-boolean value is `400`.
      properties:
        destination:
          type: string
          minLength: 1
          maxLength: 64
          description: Internal `sip_extension` / agent id, or (external conference
            enabled) an E.164 number registered in `/v1/transfer-destinations`.
            Trimmed and bounded like `BlindTransferRequest.destination`.
          example: "1001"
        consumer_agreed:
          type: boolean
          description: The consumer agreed to the external third party. Without a literal
            `true` an external destination is `422`
            `consumer_agreement_required`.
          example: true
    CallStream:
      type: object
      required:
        - stream_id
        - call_id
        - direction
        - destination_host
        - status
      properties:
        stream_id:
          type: string
          format: uuid
        call_id:
          type: string
          format: uuid
        direction:
          type: string
          enum:
            - listen
            - both
        destination_host:
          type: string
          description: The host of your `wss://` URL (the URL itself is never stored).
        status:
          type: string
          enum:
            - open
    CallControlResult:
      type: object
      properties:
        verb:
          type: string
          enum:
            - hold
            - unhold
            - mute
            - unmute
            - hangup
            - blind_transfer
            - attended_transfer_start
            - attended_transfer_complete
            - attended_transfer_cancel
            - pause_recording
            - resume_recording
        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.
        bridge_confirm_by:
          type:
            - string
            - "null"
          format: date-time
          description: "`attended_transfer_complete` only. When core checks that the
            consumer is bridged to the destination; a consumer still unbridged
            then is hung up with cause `NORMAL_UNSPECIFIED`. `null` when that
            check could not be armed."
    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
    ReportCatalog:
      type: object
      required:
        - fact
        - version
        - dimensions
        - metrics
        - detail_columns
        - limits
      properties:
        fact:
          type: string
          const: call_attempts
        version:
          type: integer
          description: Bumps when a published definition changes.
        dimensions:
          type: array
          items:
            type: object
            required:
              - key
              - type
              - pii
              - description
            properties:
              key:
                type: string
              type:
                type: string
                enum:
                  - uuid
                  - string
                  - boolean
                  - date
              pii:
                oneOf:
                  - type: string
                  - type: "null"
                description: "`null` on every published dimension: none carries personal data."
              description:
                type: string
        metrics:
          type: array
          items:
            type: object
            required:
              - key
              - unit
              - description
            properties:
              key:
                type: string
              unit:
                type: string
                enum:
                  - count
                  - seconds
                  - basis_points
              description:
                type: string
        detail_columns:
          type: array
          description: "The vocabulary of `mode: detail`. `pii` `phone` or `identifier`
            means masked unless an authorized request sets `unmask`."
          items:
            type: object
            required:
              - key
              - type
              - pii
              - description
            properties:
              key:
                type: string
              type:
                type: string
                enum:
                  - uuid
                  - string
                  - boolean
                  - timestamp
                  - integer
              pii:
                oneOf:
                  - type: string
                    enum:
                      - phone
                      - identifier
                  - type: "null"
              description:
                type: string
        limits:
          type: object
          required:
            - max_window_days
            - max_groups
            - max_dimensions
            - max_filter_values
            - max_detail_rows
          properties:
            max_window_days:
              type: integer
            max_groups:
              type: integer
            max_dimensions:
              type: integer
            max_filter_values:
              type: integer
            max_detail_rows:
              type: integer
    ReportRunRequest:
      type: object
      additionalProperties: false
      required:
        - from
        - to
      description: >
        In aggregate mode `metrics` is required unless `definition_id` names a
        saved definition; with

        `definition_id`, `dimensions`, `metrics` and `filters` must be absent

        (`422 definition_conflict`).
      properties:
        definition_id:
          type: string
          format: uuid
          description: A saved definition of the tenant (`/v1/report-definitions`); its
            keys are re-checked against the current catalog (`422
            catalog_changed`).
        mode:
          type: string
          enum:
            - aggregate
            - detail
          default: aggregate
          description: "`aggregate` requires `metrics`; `detail` requires `columns`."
        columns:
          type: array
          minItems: 1
          uniqueItems: true
          items:
            type: string
          description: Detail mode only. Keys from `detail_columns` of the catalog.
        unmask:
          type: boolean
          default: false
          description: Detail mode only. Returns the PII columns whole; `dd_` or
            `account_admin` only (`403 role_forbidden` otherwise) and audited.
        limit:
          type: integer
          minimum: 1
          maximum: 1000
          default: 1000
          description: Detail mode only. Rows per page; above the maximum it is clamped.
        after:
          type: string
          description: Detail mode only. The `next_cursor` of the previous page.
        from:
          type: string
          format: date-time
          description: Inclusive lower bound on `started_at`.
        to:
          type: string
          format: date-time
          description: Exclusive upper bound; at most 31 days after `from`.
        dimensions:
          type: array
          maxItems: 4
          uniqueItems: true
          items:
            type: string
          description: Keys from the catalog; none answers one totals row.
        metrics:
          type: array
          minItems: 1
          uniqueItems: true
          items:
            type: string
          description: Keys from the catalog.
        filters:
          type: object
          description: Dimension key to a non-empty list (at most 100) of values; only
            filterable dimensions.
          additionalProperties:
            type: array
            minItems: 1
            maxItems: 100
            items: {}
        zone:
          type: string
          default: UTC
          description: "`UTC` or an IANA `Area/Location` name; the calendar of `day`,
            `week` and `month`."
    ReportDefinitionInput:
      type: object
      additionalProperties: false
      required:
        - name
        - metrics
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 120
          description: Unique within the tenant; trimmed.
        dimensions:
          type: array
          maxItems: 4
          uniqueItems: true
          items:
            type: string
          description: Keys from the catalog.
        metrics:
          type: array
          minItems: 1
          uniqueItems: true
          items:
            type: string
          description: Keys from the catalog.
        filters:
          type: object
          description: Dimension key to a non-empty list (at most 100) of values; only
            filterable dimensions.
          additionalProperties:
            type: array
            minItems: 1
            maxItems: 100
            items: {}
    ReportDefinition:
      type: object
      required:
        - id
        - name
        - fact
        - catalog_version
        - dimensions
        - metrics
        - filters
        - created_by
        - created_at
        - updated_at
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
        fact:
          type: string
          const: call_attempts
        catalog_version:
          type: integer
          description: The catalog version the definition was last saved against.
        dimensions:
          type: array
          items:
            type: string
        metrics:
          type: array
          items:
            type: string
        filters:
          type: object
          additionalProperties:
            type: array
            items: {}
        created_by:
          oneOf:
            - type: string
              format: uuid
            - type: "null"
          description: The user who saved it; `null` when a `dd_` key did.
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    ReportDefinitionResponse:
      type: object
      required:
        - report_definition
      properties:
        report_definition:
          $ref: "#/components/schemas/ReportDefinition"
    ReportRunResult:
      type: object
      required:
        - fact
        - catalog_version
        - from
        - to
        - columns
        - row_count
        - rows
      properties:
        fact:
          type: string
          const: call_attempts
        catalog_version:
          type: integer
        mode:
          type: string
          enum:
            - detail
          description: Present (and `detail`) only on a detail page.
        from:
          type: string
          format: date-time
        to:
          type: string
          format: date-time
        zone:
          type: string
          description: Aggregate only.
        unmasked:
          type: array
          items:
            type: string
          description: Detail only. The PII columns returned whole (empty when masked).
        next_cursor:
          oneOf:
            - type: string
            - type: "null"
          description: Detail only. The `after` of the next page; `null` on the last.
        columns:
          type: array
          items:
            type: object
            required:
              - key
              - type
            properties:
              key:
                type: string
              kind:
                type: string
                enum:
                  - dimension
                  - metric
                description: Aggregate only.
              type:
                type: string
              pii:
                oneOf:
                  - type: string
                  - type: "null"
                description: Detail only.
        row_count:
          type: integer
        rows:
          type: array
          description: One object per group, keyed by the column keys. A metric that is
            undefined (`contact_rate_bp` with no attempts) is `null`.
          items:
            type: object
            additionalProperties: true
    ContactWindowsReport:
      type: object
      required:
        - from
        - to
        - campaign_id
        - basis
        - min_attempts
        - hours
        - unknown
        - best_window
        - best_window_reason
        - phones_per_person
      properties:
        from:
          type: string
          format: date-time
        to:
          type: string
          format: date-time
        campaign_id:
          oneOf:
            - type: string
              format: uuid
            - type: "null"
        basis:
          type: string
          const: consumer_local_hour
        min_attempts:
          type: integer
          description: The floor each hour of `best_window` must meet
            (`contact_window_min_attempts`).
        hours:
          type: array
          description: Always 24 rows, hour 0 to 23, in the consumer's local time.
          items:
            $ref: "#/components/schemas/ContactWindowHour"
        weekdays:
          type: array
          description: Always 7 rows, ISO weekday 1 (Monday) to 7 (Sunday), in the
            consumer's local time. Additive (core#1980); optional for clients
            written before it.
          items:
            $ref: "#/components/schemas/ContactWindowWeekday"
        applied:
          type: boolean
          description: Whether the tenant's `best_time_ranking_enabled` knob is on, i.e.
            whether the dialing feed orders leads by this data. Additive
            (core#1980).
        source:
          type: string
          enum:
            - campaign
            - portfolio
            - none
          description: The level that speaks for `weights` — the campaign's own history,
            the tenant's portfolio, or `none` (no evidence, neutral order).
            Additive (core#1980).
        weights:
          type: array
          description: The weight the dialer applies to each (weekday, local hour) cell
            inside the legal window [08:00, 21:00) while `applied` is true (with
            `applied` false it keeps the neutral order and this is what it would
            apply) — 7 x 13 rows, or empty if it could not be computed. Trailing
            90 days; not narrowed by `from`/`to`. Additive (core#1980).
          items:
            $ref: "#/components/schemas/ContactWindowWeight"
        unknown:
          type: object
          description: Attempts whose local hour is not knowable. Never redistributed over
            `hours`.
          required:
            - attempts
            - contacts
            - no_zone
            - ambiguous_zone
          properties:
            attempts:
              type: integer
            contacts:
              type: integer
            no_zone:
              type: integer
              description: Attempts with no usable frozen zone.
            ambiguous_zone:
              type: integer
              description: Attempts whose area code spans several zones or disagrees with the
                declared one.
        best_window:
          oneOf:
            - $ref: "#/components/schemas/ContactWindow"
            - type: "null"
        best_window_reason:
          oneOf:
            - type: string
              enum:
                - insufficient_attempts
                - no_resolved_attempts
            - type: "null"
          description: Why `best_window` is `null`; `null` when a window is named.
        phones_per_person:
          type: object
          required:
            - persons
            - phones
            - average
          properties:
            persons:
              type: integer
              description: Distinct `consumer_ref` with an active account.
            phones:
              type: integer
              description: Sum of distinct phones per person.
            average:
              oneOf:
                - type: number
                - type: "null"
              description: "`phones / persons`, two decimals; `null` with no active person."
    ContactWindowHour:
      type: object
      required:
        - hour
        - attempts
        - contacts
        - contact_rate
      properties:
        hour:
          type: integer
          minimum: 0
          maximum: 23
        attempts:
          type: integer
        contacts:
          type: integer
        contact_rate:
          oneOf:
            - type: number
            - type: "null"
          description: "`contacts / attempts`, four decimals; `null` when there were no
            attempts."
    ContactWindowWeekday:
      type: object
      required:
        - weekday
        - attempts
        - contacts
        - contact_rate
      properties:
        weekday:
          type: integer
          minimum: 1
          maximum: 7
          description: ISO weekday, 1 = Monday.
        attempts:
          type: integer
        contacts:
          type: integer
        contact_rate:
          oneOf:
            - type: number
            - type: "null"
          description: "`contacts / attempts`, four decimals; `null` when there were no
            attempts."
    ContactWindowWeight:
      type: object
      required:
        - weekday
        - hour
        - weight
        - source
        - attempts
        - contacts
      properties:
        weekday:
          type: integer
          minimum: 1
          maximum: 7
        hour:
          type: integer
          minimum: 8
          maximum: 20
        weight:
          type: number
          minimum: 0.5
          maximum: 1.5
          description: 1.0 is neutral.
        source:
          type: string
          enum:
            - campaign
            - portfolio
            - none
        attempts:
          type: integer
          description: Attempts in this cell at the level that spoke; 0 when `source` is
            `none`.
        contacts:
          type: integer
    ContactWindow:
      type: object
      required:
        - start_hour
        - end_hour
        - attempts
        - contacts
        - contact_rate
      properties:
        start_hour:
          type: integer
          minimum: 0
          maximum: 22
        end_hour:
          type: integer
          minimum: 2
          maximum: 24
          description: Exclusive; always `start_hour + 2`.
        attempts:
          type: integer
        contacts:
          type: integer
        contact_rate:
          type: number
    PromisesPipelineFigures:
      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
        resolved_30d:
          type: object
          description: The kept rate's numerator and denominator — promises resolved with
            `promised_date` in the last 30 days.
          properties:
            kept:
              type: integer
            broken:
              type: integer
        kept_rate_30d:
          oneOf:
            - type: number
            - type: "null"
          description: >
            `kept / (kept + broken)` over `resolved_30d`; `null` = none
            resolved.

            Client-reported: a promise only resolves through `PATCH
            /v1/promises/{id}`.
    PromisesPipelineRow:
      allOf:
        - $ref: "#/components/schemas/PromisesPipelineFigures"
        - type: object
          properties:
            campaign_id:
              oneOf:
                - type: string
                  format: uuid
                - type: "null"
              description: "`null` = promises with no attempt, or whose attempt has no
                campaign."
    PromisesPipelineSummary:
      allOf:
        - $ref: "#/components/schemas/PromisesPipelineFigures"
        - type: object
          properties:
            campaign_id:
              oneOf:
                - type: string
                  format: uuid
                - type: "null"
              description: The `campaign_id` filter echoed back; `null` = whole tenant.
            window_days:
              type: integer
            by_campaign:
              type: array
              description: One row per campaign with at least one promise, the `null` row
                last; the rows add up to the totals.
              items:
                $ref: "#/components/schemas/PromisesPipelineRow"
    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"
        window:
          type: string
          enum:
            - 7d
            - 30d
            - 90d
            - all
          description: Present only with `window=`.
        as_of:
          type: string
          format: date-time
          description: "Present only with `window=`: when the numbers were computed. Never
            older than the server's snapshot `max_age`."
        granularity:
          type: string
          enum:
            - day
          description: Present only with `granularity=day`.
        tz:
          type: string
          description: The zone the days were cut in. Present only with `granularity=day`.
        by_day:
          type: array
          description: Present only with `granularity=day`. Every local day the window
            touches × every campaign with a universe, ordered by day then
            campaign; zeros included. The first and last day are partial when
            `from`/`to` are not local midnights. Counts add up across campaigns
            (a debt belongs to one campaign), not across days.
          items:
            $ref: "#/components/schemas/PenetrationDayRow"
    PenetrationDayRow:
      type: object
      properties:
        day:
          type: string
          format: date
        campaign_id:
          type: string
          format: uuid
        unique_accounts:
          type: integer
          description: The campaign's universe — the same number its `by_campaign` row
            reports.
        attempted:
          type: integer
        reached:
          type: integer
        effective_contact:
          type: integer
        attempted_pct:
          type: number
          description: Over `unique_accounts`.
        reached_pct:
          type: number
          description: Over `unique_accounts`.
        effective_contact_pct:
          type: number
          description: Over `unique_accounts`.
    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
    AbandonmentReport:
      type: object
      required:
        - campaign_id
        - measured_at
        - period_days
        - ceiling_percent
        - anchored_at
        - predicate
        - periods
      properties:
        campaign_id:
          type: string
          format: uuid
        measured_at:
          type: string
          format: date-time
        period_days:
          type: integer
          enum:
            - 30
        ceiling_percent:
          type: integer
          enum:
            - 3
        anchored_at:
          oneOf:
            - type: string
              format: date-time
            - type: "null"
          description: The campaign's first placed dial; `null` = no dial yet (and no
            period).
        predicate:
          type: object
          required:
            - counts
            - reported_apart
          properties:
            counts:
              type: array
              description: The reasons the rate counts as abandoned.
              items:
                type: string
                enum:
                  - no_agent
                  - ai_stream_lost
                  - agent_bridge_failed
                  - handoff_failed
                  - late_bridge
            reported_apart:
              type: array
              description: Reasons reported per period and NOT counted in the rate.
              items:
                type: string
                enum:
                  - no_agent
                  - ai_stream_lost
                  - agent_bridge_failed
                  - handoff_failed
                  - late_bridge
        periods:
          type: array
          description: One entry per 30-day period with at least one placed dial, oldest
            first.
          items:
            $ref: "#/components/schemas/AbandonmentPeriod"
    AbandonmentPeriod:
      type: object
      required:
        - index
        - starts_at
        - ends_at
        - complete
        - placed
        - answered
        - answered_machine
        - answered_live
        - abandoned
        - abandoned_by_reason
        - handoff_failed
        - late_bridge
        - rate
        - exceeds_ceiling
      properties:
        index:
          type: integer
          minimum: 0
          description: "`[anchored_at + 30·index days, + 30 days)`."
        starts_at:
          type: string
          format: date-time
        ends_at:
          type: string
          format: date-time
          description: Exclusive.
        complete:
          type: boolean
          description: "`ends_at` is not after `measured_at`."
        placed:
          type: integer
          minimum: 0
        answered:
          type: integer
          minimum: 0
        answered_machine:
          type: integer
          minimum: 0
          description: Answered with an AMD `machine` verdict.
        answered_live:
          type: integer
          minimum: 0
          description: "The denominator: `answered - answered_machine`."
        abandoned:
          type: integer
          minimum: 0
          description: The numerator; the sum of `abandoned_by_reason`.
        abandoned_by_reason:
          type: object
          additionalProperties:
            type: integer
            minimum: 0
          description: >
            One key per reason in `predicate.counts`. Readers must not assume a
            closed set of keys: `agent_bridge_failed` was added in core#1383.
        handoff_failed:
          type: integer
          minimum: 0
          description: Voice-AI handoffs with no seat; not in the rate.
        late_bridge:
          type: integer
          minimum: 0
          description: >
            Live answers not counted as abandoned whose consumer leg was bridged
            more than 2 s after `answered_at` (core#1426); not in the rate.
        rate:
          oneOf:
            - type: number
              minimum: 0
              maximum: 1
            - type: "null"
          description: "`abandoned / answered_live`, 4 decimals; `null` with no live
            answer."
        exceeds_ceiling:
          type: boolean
          description: "`abandoned * 100 > 3 * answered_live`."
    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
    SkipTraceLookupRequest:
      type: object
      required:
        - request_key
        - subject
      additionalProperties: false
      properties:
        request_key:
          type: string
          minLength: 8
          maxLength: 128
          description: The caller's idempotency key; one lookup per key per tenant.
        subject:
          type: object
          minProperties: 1
          additionalProperties: false
          properties:
            name:
              type: string
              maxLength: 256
            phone:
              type: string
              maxLength: 256
            address:
              type: string
              maxLength: 256
            email:
              type: string
              maxLength: 256
    SkipTraceLookupResult:
      type: object
      required:
        - results
        - cost_usd
      properties:
        results:
          type: array
          items:
            type: object
            additionalProperties: true
        cost_usd:
          type: string
          description: Decimal string, USD.
    SkipTraceCap:
      type: object
      properties:
        max_queries_per_month:
          type:
            - integer
            - "null"
          minimum: 1
          maximum: 100000000
        max_usd_per_month:
          type:
            - string
            - "null"
          description: Decimal string in (0, 100000000), at most 4 decimals.
    SkipTraceCapState:
      type: object
      required:
        - cap
        - usage
      properties:
        cap:
          $ref: "#/components/schemas/SkipTraceCap"
        usage:
          type: object
          required:
            - queries
            - spent_usd
          properties:
            queries:
              type: integer
            spent_usd:
              type: string
    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.
        balance_settled_usd:
          type:
            - string
            - "null"
          description: >
            What of the sale already left the tenant's balance: the sum of the
            lines' `settled_from_balance_usd`. Decimal as string. FROZEN with
            the document (core#2199, ADR #276); `null` on an invoice issued
            before the figure existed, which means «not computed», never
            «nothing was settled».
        amount_due_usd:
          type:
            - string
            - "null"
          description: >
            `total - balance_settled_usd`, taxes included (their framing is the
            founder's, ADR #73 decision 4). Decimal as string. `total` and every
            `amount` stay the sale: a telecom sale paid from a prepaid balance
            is the same sale. `null` exactly when `balance_settled_usd` is.
        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.
        settled_from_balance_usd:
          type:
            - string
            - "null"
          description: >
            How much of `amount` already left the tenant's balance. Present on
            the four METERED lines (`house_minutes`, `inbound_minutes`, `sms`,
            `voice_ai`), `null` on every other line and on lines issued before
            the figure existed. At the close the balance is brought to the line
            (one `adjustment` of the wallet per line with debits, in the
            direction that is needed), so it equals `amount` there; on
            `voice_ai` the $200 monthly minimum is not drawn from the balance
            and the top-up stays in `amount_due_usd` (ADR #276). Decimal as
            string.
    TierPendingChange:
      type: object
      description: |
        A tier change that has been ASKED FOR and whose PRICE has not started
        applying yet. The tenant already enjoys `requested_tier`; the open
        period keeps billing at `billing_tier` until `effective_from`.

        Derived from two facts that are already stored — the open period's
        frozen row and the live tier — so it cannot drift out of step with
        either. `requested_price_usd` is null only when the live tier has no
        catalog entry: a price the customer reads as "what I will pay" is not
        invented.
      required:
        - requested_tier
        - requested_price_usd
        - billing_tier
        - billing_price_usd
        - period_start
        - period_end
        - effective_from
      properties:
        requested_tier:
          type: string
          description: The tier the tenant asked for, and already has the entitlements of.
        requested_price_usd:
          type:
            - string
            - "null"
          description: Monthly catalog price of the requested tier. String, so cents
            survive JSON.
        billing_tier:
          type: string
          description: The tier the OPEN period bills at — the one it started on.
        billing_price_usd:
          type: string
          description: |
            What the open period bills at. This is the price the freeze
            SNAPSHOTTED, not one derived from the tier today: the catalog is
            append-only and can gain a new version of the same tier.
        period_start:
          type: string
          format: date
        period_end:
          type: string
          format: date
          description: Last day of the open period (inclusive — the period is a closed
            range).
        effective_from:
          type: string
          format: date
          description: The day the requested tier starts BILLING — the day after
            `period_end`.
    BillingTier:
      type: object
      required:
        - tenant_id
        - tier
        - pending_change
      properties:
        tenant_id:
          type: string
          format: uuid
        tier:
          type: string
          description: The tier in force RIGHT NOW — the one product entitlements read.
        pending_change:
          oneOf:
            - $ref: "#/components/schemas/TierPendingChange"
            - type: "null"
          description: Null when nothing is pending. That is an answer, not a hole.
    BillingTierUpgrade:
      type: object
      required:
        - tenant
        - tier
        - pending_change
      properties:
        tenant:
          $ref: "#/components/schemas/Tenant"
        tier:
          type: string
          description: The tier now in force — the one just asked for.
        pending_change:
          oneOf:
            - $ref: "#/components/schemas/TierPendingChange"
            - type: "null"
          description: |
            Null in one real case: the open period was ALREADY frozen at the
            destination (an out-and-back within the same month), so the month
            already bills at what the tenant now has.
    BillingTierCatalog:
      type: object
      required:
        - tenant_id
        - tier
        - current_price_usd
        - tiers
      properties:
        tenant_id:
          type: string
          format: uuid
        tier:
          type: string
          description: The tier in force RIGHT NOW. It is listed in `tiers` only if it is
            still on sale.
        current_price_usd:
          type:
            - string
            - "null"
          description: |
            Catalog price of the tier in force — the price an upgrade must
            strictly exceed. Not necessarily what the open period bills at (see
            `GET /v1/billing/tier`). Null when the tier in force has no catalog
            entry; then no tier is an upgrade.
        tiers:
          type: array
          description: Every tier on sale that the tenant may be moved to, cheapest first.
          items:
            $ref: "#/components/schemas/BillingTierOffer"
        pay_as_you_go:
          description: >
            The pay-as-you-go entry mode: ONE seat and ALL the consumption drawn

            from the prepaid balance, with no allowance pool. It is the
            `prepaid`

            scheme on the `starter` tier, so `seat_price_usd` is the catalog
            price

            of that entry per seat and month (read from the row, never restated)

            and `seats` is its seat floor. Null when the entry tier is not on
            sale.
          oneOf:
            - $ref: "#/components/schemas/BillingPayAsYouGo"
            - type: "null"
        scheme_terms:
          type: array
          description: >
            The terms of each billing scheme as data (`core#2835`): the floor of
            a

            balance top-up and whether the seat is charged. `standard` is what

            every plan carries unless it was given another; `developer` charges

            no seat. The floor listed is the one the payment path enforces.
          items:
            $ref: "#/components/schemas/BillingSchemeTerms"
    BillingSchemeTerms:
      type: object
      required:
        - terms_key
        - min_topup_usd
        - seat_charged
      properties:
        terms_key:
          type: string
          description: Key of the scheme terms (`standard`, `developer`).
        min_topup_usd:
          type: string
          description: Floor of one balance top-up for a plan carrying these terms. Money
            as a string.
        seat_charged:
          type: boolean
          description: False when the invoice carries no seat line.
    BillingPayAsYouGo:
      type: object
      required:
        - scheme
        - tier
        - seats
        - seat_price_usd
        - min_topup_usd
        - billed_from
        - included
      properties:
        scheme:
          type: string
          enum:
            - prepaid
        tier:
          type: string
          description: The tier key the mode is signed up on (`starter`).
        seats:
          type: integer
          description: "Seats the mode starts with: the seat floor of that tier."
        seat_price_usd:
          type: string
          description: Catalog price of one seat per month on that tier. Money as a string
            so cents survive JSON.
        min_topup_usd:
          type: string
          description: The floor of one balance top-up, the one the payment path enforces.
        billed_from:
          type: string
          enum:
            - balance
          description: Every metered line is drawn from the prepaid balance.
        included:
          type: object
          description: The metered allowances the mode includes. Always none.
          required:
            - minutes
            - sms_segments
            - ai_minutes
          properties:
            minutes:
              type: string
            sms_segments:
              type: integer
            ai_minutes:
              type: string
    BillingTierOffer:
      type: object
      required:
        - tier
        - label
        - version
        - monthly_price_usd
        - currency
        - effective_from
        - entitlements
        - current
        - upgrade
      properties:
        tier:
          type: string
          description: The tier key the upgrade body names.
        label:
          type: string
        version:
          type: integer
          description: The catalog version on sale (the catalog is append-only; a price
            change is a new version).
        monthly_price_usd:
          type: string
          description: |
            Catalog price of the version on sale, PER SEAT and per month (the
            invoice's `seats` line multiplies it by the billable seats). String,
            so cents survive JSON.
        currency:
          type: string
          enum:
            - USD
        effective_from:
          type: string
          format: date
          description: The date the catalog version on sale took effect.
        entitlements:
          type: object
          description: |
            The capacity the tier grants, as the catalog stores it. On
            `max_seats` and `max_concurrent_channels` null means UNLIMITED; on
            `included_ai_minutes` null means the voice-AI add-on is not offered.
          required:
            - max_seats
            - max_concurrent_channels
            - included_minutes
            - included_sms_segments
            - included_ai_minutes
            - included_dids
          properties:
            max_seats:
              type:
                - integer
                - "null"
              description: Null means unlimited.
            max_concurrent_channels:
              type:
                - integer
                - "null"
              description: Null means unlimited.
            included_minutes:
              type:
                - string
                - "null"
            included_sms_segments:
              type:
                - integer
                - "null"
            included_ai_minutes:
              type:
                - string
                - "null"
              description: Null means the voice-AI add-on is not offered on this tier; a
                number (zero included) means it is.
            included_dids:
              type:
                - integer
                - "null"
        current:
          type: boolean
          description: This is the tier in force.
        upgrade:
          type: boolean
          description: "`POST /v1/billing/tier/upgrade` with this tier would be accepted:
            on sale and strictly more expensive than `current_price_usd`."
    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
            - held_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. Collateral on file. It does NOT enter

                `available_usd`: only the `credit_limit_usd` derived from it
                does.
            credit_limit_usd:
              type: string
              description: Decimal as string.
            held_usd:
              type: string
              description: |
                Decimal as string. Σ of the tenant's open holds (status `held`):
                what live calls have already reserved. Released and captured
                holds do not count. It is the amount `available_usd` subtracts.
            available_usd:
              type: string
              description: |
                Decimal as string. `cash + grant + credit_limit − held_usd`:
                spendable right now. `deposit_usd` is NOT a term, and what is
                held is subtracted.
          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
    BillingLedgerPage:
      type: object
      required:
        - entries
        - next_cursor
      properties:
        entries:
          type: array
          items:
            $ref: "#/components/schemas/BillingLedgerEntry"
        next_cursor:
          type:
            - string
            - "null"
          description: Pass it as `cursor` for the next page; null on the last one.
    BillingLedgerEntry:
      type: object
      required:
        - id
        - entry_type
        - event_type
        - ref_uuid
        - billable_event_id
        - cash_delta
        - grant_delta
        - deposit_delta
        - occurred_on
        - recorded_at
      properties:
        id:
          type: string
          format: uuid
        entry_type:
          type: string
          enum:
            - topup
            - debit
            - refund
            - adjustment
            - subscription_grant
            - grant_expiry
            - deposit_hold
            - deposit_release
            - deposit_apply
        event_type:
          type:
            - string
            - "null"
          description: The billable event type for a `debit`; null for the other movements.
        ref_uuid:
          type: string
          description: The billable event id for a `debit`, the payment idempotency key
            for a `topup`, `YYYY-MM` for a period grant.
        billable_event_id:
          type:
            - string
            - "null"
          format: uuid
          description: The usage event that caused a `debit`; null for the other movements.
        cash_delta:
          type: string
          description: Signed decimal, as a string.
        grant_delta:
          type: string
          description: Signed decimal, as a string.
        deposit_delta:
          type: string
          description: Signed decimal, as a string.
        occurred_on:
          type: string
          format: date
          description: The UTC day the movement belongs to.
        recorded_at:
          type: string
          format: date-time
          description: When the entry landed.
    BillingCost:
      type: object
      description: >
        Gross ledger cost of ONE dimension over `[from, to)` (ADR #218). Money
        is a string, never a float. Every ledger row is in exactly one group;
        the groups of each currency add up to `gross_total`.
      required:
        - group_by
        - from
        - to
        - ledger_days
        - sources
        - groups
        - gross_total
        - reconciliation
      properties:
        group_by:
          type: string
          enum:
            - campaign
            - carrier
            - agent
        from:
          type: string
          format: date-time
        to:
          type: string
          format: date-time
        ledger_days:
          type: object
          description: The closed UTC-day range the ledger was read over.
          required:
            - from
            - to
          properties:
            from:
              type: string
              format: date
            to:
              type: string
              format: date
        sources:
          type: object
          description: >
            How many UTC days of `ledger_days` were read from the daily rollup
            (closed days), summed directly from the ledger (the two most recent)
            or were empty (no events).
          required:
            - rollup
            - direct
            - empty
          properties:
            rollup:
              type: integer
              minimum: 0
            direct:
              type: integer
              minimum: 0
            empty:
              type: integer
              minimum: 0
        groups:
          type: array
          items:
            $ref: "#/components/schemas/BillingCostGroup"
        gross_total:
          type: array
          description: The ledger sum per currency.
          items:
            $ref: "#/components/schemas/BillingCostTotal"
        reconciliation:
          type: object
          required:
            - basis
            - groups_sum
            - invoice
            - not_allocated
            - note
          properties:
            basis:
              type: string
              enum:
                - ledger_gross
            groups_sum:
              type: array
              description: The sum of `groups` per currency; equal to `gross_total` on every
                200.
              items:
                $ref: "#/components/schemas/BillingCostTotal"
            invoice:
              type: object
              description: >
                The lines of `GET /v1/billing/summary` over the same window, or
                `status: unavailable` with the reason when the window cannot be
                priced (the cost itself does not depend on it).
              required:
                - status
              properties:
                status:
                  type: string
                  enum:
                    - available
                    - unavailable
                reason:
                  type: string
                  description: Only when `unavailable`.
                currency:
                  type: string
                total_usd:
                  type: string
                lines:
                  type: array
                  items:
                    type: object
                    required:
                      - kind
                      - amount_usd
                      - gross_amount_usd
                    properties:
                      kind:
                        type: string
                      amount_usd:
                        type: string
                      gross_amount_usd:
                        type:
                          - string
                          - "null"
                        description: The untouched ledger sum, on the lines that carry an allowance;
                          `null` otherwise.
            not_allocated:
              type: array
              description: What the invoice applies on top of the gross and this resource does
                NOT split across groups.
              items:
                type: string
            note:
              type: string
    BillingCostGroup:
      type: object
      required:
        - key
        - unattributed
        - currency
        - billed_by
        - amount
        - events
      properties:
        key:
          type:
            - string
            - "null"
          description: The campaign / carrier / agent id; `null` in the unattributed bucket.
        unattributed:
          type: boolean
        currency:
          type: string
        billed_by:
          type:
            - string
            - "null"
          enum:
            - platform
            - tenant_carrier
            - null
          description: Who bills the telecom minutes of those calls.
        amount:
          type: string
        events:
          type: integer
          minimum: 0
    BillingCostTotal:
      type: object
      required:
        - currency
        - amount
        - events
      properties:
        currency:
          type: string
        amount:
          type: string
        events:
          type: integer
          minimum: 0
    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
              description: The `from` that was asked for (inclusive).
            to:
              type: string
              format: date-time
              description: The `to` that was asked for (EXCLUSIVE).
            day_ledger:
              type: object
              required:
                - from_day
                - to_day
                - kinds
              description: >
                What the day-ledger lines actually summed (core#1383). Those
                lines read `billable_events`, which is keyed by UTC day, so a
                window that does not start and end at UTC midnight sums its
                WHOLE days: `from=16:49Z&to=17:00Z` reads that entire day. This
                is the closed range they read, derived from `[from, to)` (a `to`
                at exact UTC midnight is a cut, so its day is not in it). It
                applies to `kinds` only: `voice_ai` meters the exact `[from,
                to)`, and the monthly lines resolve as of its end.
              properties:
                from_day:
                  type: string
                  format: date
                  description: First UTC day summed (inclusive).
                to_day:
                  type: string
                  format: date
                  description: Last UTC day summed (inclusive).
                kinds:
                  type: array
                  description: The line kinds read over this day range.
                  items:
                    type: string
                    enum:
                      - house_minutes
                      - inbound_minutes
                      - did_rent
                      - sms
        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
              settled_from_balance_usd:
                type: number
                description: >
                  Present on the four METERED lines (`house_minutes`,
                  `inbound_minutes`, `sms`, `voice_ai`) only: what the tenant's
                  balance has been debited for this line's meter over its days
                  (core#2199, ADR #276). It is the ledger's fact so far, not a
                  second price: `amount_usd` is the sale and does not change.
                  The frozen invoice brings it to `amount_usd` at the close.
              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
        balance_settled_usd:
          type: number
          description: >
            The sum of the lines' `settled_from_balance_usd` (core#2199, ADR
            #276).
        amount_due_usd:
          type: number
          description: >
            `total_usd - balance_settled_usd`, taxes included. `total_usd` is
            unchanged: the invoice stays the sale.
    AuditChainCitation:
      type: object
      description: |
        One sealed link of the compliance audit chain plus the integrity verdict
        over it — the resolvable form of the `decision_id` a refusal hands back.
      properties:
        decision:
          type: object
          description: The sealed link, as stored.
          properties:
            decision_id:
              type: string
              format: uuid
            tenant_id:
              type: string
              format: uuid
            seq:
              type: integer
              description: Position in THIS tenant's chain; 1 is the first link.
            channel:
              type: string
              enum:
                - voice
                - sms
            allowed:
              type: boolean
              description: Whether the operation was permitted. `false` is the litigated case.
            reason_code:
              type: string
            rule_id:
              type: string
            policy_version:
              type: string
            subject_table:
              type: string
              description: |
                Which evidence row this link seals (`call_attempts`,
                `gate_blocks`, `sms_messages`, `tenant_posture_events`,
                `call_disclosure_events`). Empty string when the decision left
                no evidence row — not an error.
            subject_id:
              type:
                - string
                - "null"
              format: uuid
              description: Id of that evidence row; `null` when `subject_table` is empty.
            debt_ref:
              type: string
              description: Your STABLE account reference, not our row id. Empty when there is
                none.
            campaign_id:
              type:
                - string
                - "null"
              format: uuid
              description: "`null` for a decision outside any campaign (e.g. a one-off `POST
                /v1/sms/messages`), and for every posture-change link."
            to_number_masked:
              type: string
              description: Last-4 only (`***1234`). The E.164 is never sealed and never
                returned.
            decided_at:
              type: string
              format: date-time
            sealed_at:
              type: string
              format: date-time
              description: When the link was written — same transaction as the evidence row it
                seals.
            payload:
              type: object
              description: |
                The canonical JSON that was hashed, verbatim. This is what makes
                the citation checkable without trusting us: re-run the recipe
                over it.
              additionalProperties: true
        verification:
          type: object
          description: The integrity verdict over this ONE link.
          properties:
            status:
              type: string
              enum:
                - intact
                - intact_unwitnessed
                - tampered
              description: |
                `intact` only when ALL THREE booleans below hold.
                `intact_unwitnessed` when the first two hold and there is no
                successor yet (the tip of the chain). `tampered` when any of
                the three fails.
            payload_hash_matches:
              type: boolean
              description: |
                This link's payload hashes to the `hash` stored BESIDE IT.
                Internal consistency, not authenticity: a database-level
                adversary can rewrite both columns at once and turn this green.
                `successor_chains_to_this` is what rules that out.
            chains_to_previous:
              type: boolean
              description: |
                Its `prev_hash` is the `hash` of link `seq - 1` (or the genesis
                when `seq` is 1). `false` also when that predecessor is GONE —
                which is how a deletion becomes visible.
            successor_chains_to_this:
              type:
                - boolean
                - "null"
              description: |
                The link at `seq + 1` still carries this link's `hash` as its
                `prev_hash`. `null` when there is no successor (this is the
                newest link — see `is_chain_tip`). A coherent rewrite of this
                link alone flips it to `false`.
            is_chain_tip:
              type: boolean
              description: |
                `true` when no later link exists yet. At the tip nothing
                witnesses this link, so a coherent rewrite of it cannot be
                detected from inside the chain, and `status` says
                `intact_unwitnessed` rather than `intact`.
            seq:
              type: integer
            hash:
              type: string
              description: Lowercase hex sha256 of this link.
            prev_hash:
              type: string
              description: Lowercase hex sha256 this link chains back to.
            genesis_hash:
              type: string
              description: >
                The fixed root of every tenant chain, published so the recipe is

                reproducible from scratch. It is a CONSTANT, and the value in
                the

                example above is the real one — not a placeholder. `seq: 1`

                chains back to it.
            algorithm:
              type: string
              const: sha256
            recipe:
              type: string
              description: The literal recipe to re-run.
            canonical_json:
              type: string
              description: How `payload` must be serialized before hashing.
            verified_at:
              type: string
              format: date-time
    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
        filters:
          type: object
          description: The audit filters applied (`null` = not applied).
          properties:
            campaign_id:
              oneOf:
                - type: string
                  format: uuid
                - type: "null"
            debt_id:
              oneOf:
                - type: string
                  format: uuid
                - type: "null"
            phone_last4:
              oneOf:
                - type: string
                - type: "null"
        totals:
          type: object
          properties:
            total:
              type: integer
            unit:
              type: string
              const: deduplicated_gate_block_days
              description: |
                The unit of `total`, `by_gate` and every `by_campaign` count.
            unit_description:
              type: string
              description: |
                Human-readable gloss of `unit`, served so the customer-facing
                artifact states what its headline number means: one deduplicated
                evidence row per unit, NOT one blocked call. A campaign refusal
                counts once per `(tenant, debt, gate, campaign, UTC day)`; an
                ad-hoc refusal (`POST /v1/sms`, no debt and no campaign) counts
                once per `(tenant, gate, masked last-4, UTC day)`, which
                additionally collapses two different numbers sharing their last
                four digits (ADR #8; migration `20260728000002`).
            by_gate:
              type: object
              additionalProperties:
                type: integer
        by_campaign:
          type: array
          description: Sorted by blocked count (desc); same unit as `totals.unit`.
          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 (≤25 when `debt_id` or
            `phone` is filtered), 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. Each carries `source`: `platform`
            (recorded by this system) or `cms_import` (declared by the client on
            import).
          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
        suppression_events:
          type: array
          description: >
            Version 5 (core#2554). The history of the opt-outs of the debt's
            numbers and of its

            `consumer_ref`, oldest first: `kind` (`dnc_added`, `dnc_removed`,
            `consent_granted`,

            `consent_revoked`, `cease_filed`), `actor` (`user:<id>`, `key:<id>`,
            `system:sms_inbound`,

            `system:ivr`…), `source`, `channel`, `effective_at` and
            `occurred_at`. The number is

            published as its last four digits only. Each event is sealed in the
            tenant chain

            (`subject_table: suppression_events`, in `audit_chain.links`).
            Events exist from the

            release that introduced the history: a suppression older than that
            has none.
          items:
            type: object
        supervision_actions:
          type: array
          items:
            type: object
        conference_legs:
          type: array
          description: >
            Version 3 (core#1701, ADR #223). The THIRD PARTIES that joined a
            call of the debt

            as a conference leg: each leg's own `call_attempts` rows, originals
            and

            corrections (`corrects_id`). They carry no debt, so `call_attempts`
            never lists

            them; `compliance_snapshot.conference_of` names the call they joined
            and the

            closing correction states when and why the leg ended
            (`conference_call_ended`

            when the call ended first).
          items:
            type: object
        conference_actions:
          type: array
          description: >
            Version 3. The `conference_add` / `conference_remove` commands on
            the debt's calls:

            who was added (`destination`), the leg (`conf=<uuid>` in `reason`),
            the consumer's

            attestation (`consumer_agreed=true`) and the result.
          items:
            type: object
        policy_versions:
          type: array
          items:
            type: string
        disclosure_events:
          type: array
          description: >
            Version 2 (core#1587). EVERY attestation of the collection notice on
            the

            debt's calls, also the ones a later attestation rectified
            (`supersedes_id`).

            They state what the agent DECLARED. The chain links that seal them
            travel in

            `audit_chain.links` (`subject_table: call_disclosure_events`).
          items:
            $ref: "#/components/schemas/DisclosureEvent"
        disclosure_summary:
          type: object
          description: >
            Version 2. Counts per attempt of the debt: `contacted` (in scope or
            already

            attested) = `attested` + `unattested`, where `attested` includes the

            `declared_not_delivered` ones (listed apart). `undecidable` calls
            have no final

            row yet and are not in `contacted`.
          properties:
            contacted:
              type: integer
            attested:
              type: integer
            declared_not_delivered:
              type: integer
            unattested:
              type: integer
            undecidable:
              type: integer
        disclosure_scripts:
          type: array
          description: >
            Version 4 (core#1691, ADR #219 Decision 7). Every script version
            that some

            attestation of the debt NAMES (`script_version_id` on a
            `disclosure_events` row), with the

            full `text`, `text_sha256`, `legal_ref`, `authored_by`, `status` and
            the `approval` /

            `retirement` events (`actor`, `reviewed_by`, `occurred_at`). A
            version retired after the

            call is still listed; an attestation that names no version adds
            nothing. Empty when

            no attestation names one.
          items:
            $ref: "#/components/schemas/DisclosureScript"
        claims:
          type: object
          description: >
            Version 2, extended in version 4. Fixed text, identical in every
            packet: what the

            product asserts about the collection notice (the agent's
            declaration) and what it does

            NOT (that the notice was spoken, heard or understood, or that its
            text satisfies a

            rule). The `script` block (version 4) says the same of the script
            versions: they were

            approved by the recorded actor with the recorded reference and
            reviewer, and the

            product does not assert that any text satisfies any rule or
            jurisdiction.
          additionalProperties: true
        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"
    DefensePacketArchive:
      type: object
      description: |
        The append-only attestation of ONE archived packet. `retention_mode`,
        `retain_until` and `version_id` are what the archive store answered when
        the object was read back after the write.
      required:
        - id
        - debt_id
        - artifact_sha256
        - integrity_digest
        - object_key
        - store
        - retention_mode
        - tsa_url
        - tsa_gen_time
        - created_by
        - created_at
      properties:
        id:
          type: string
          format: uuid
        debt_id:
          type: string
          format: uuid
        artifact_sha256:
          type: string
          description: SHA-256 (hex) of the archived ZIP bytes; the digest the authority
            time-stamped.
        integrity_digest:
          type: string
          description: The packet's own `integrity.digest` (hex) at archive time.
        object_key:
          type: string
        version_id:
          type:
            - string
            - "null"
          description: The object version the store wrote. Null only for `bucket_lock`.
        store:
          type: string
        retention_mode:
          type: string
          enum:
            - COMPLIANCE
            - GOVERNANCE
            - bucket_lock
        retain_until:
          type:
            - string
            - "null"
          format: date-time
          description: Retention end the store confirmed. Null only for `bucket_lock`,
            where the bucket rule governs.
        tsa_url:
          type: string
          format: uri
        tsa_gen_time:
          type: string
          format: date-time
          description: The time the authority put in the token (`genTime`).
        created_by:
          type: string
        created_at:
          type: string
          format: date-time
    DefensePacketArchiveResponse:
      type: object
      required:
        - archive
      properties:
        archive:
          $ref: "#/components/schemas/DefensePacketArchive"
    TenantCallingWindow:
      type: object
      additionalProperties: false
      required:
        - timezone
        - weekly
      description: >
        The tenant's own calling hours (core#1527, R19 key
        `tenant_calling_window`).

        It only NARROWS: R18 (per destination) and the campaign's window still
        apply.

        The hours are read in `timezone` (the tenant's, NOT the destination's).
      properties:
        timezone:
          type: string
          example: America/Chicago
          description: An IANA zone name. A zone that does not resolve is `422`.
        weekly:
          $ref: "#/components/schemas/CampaignWeeklyWindow"
    InboundRouteHours:
      type: object
      additionalProperties: false
      required:
        - timezone
        - weekly
        - closed
      description: >
        The hours of an inbound route (core#2530, ADR #304). `timezone` +
        `weekly` are the same window the tenant's SMS

        and calling hours use (`TenantCallingWindow`): a day without an entry is
        closed and a window does not cross

        midnight (a night shift is two days). A date in `holidays` closes that
        whole local day, whatever `weekly` says.

        It is judged ONCE, when the call enters, on the instant it arrived and
        in this zone.
      properties:
        timezone:
          type: string
          example: America/Chicago
          description: An IANA zone name. A zone that does not resolve is `422`.
        weekly:
          $ref: "#/components/schemas/CampaignWeeklyWindow"
        holidays:
          type: array
          maxItems: 366
          uniqueItems: true
          default: []
          items:
            type: string
            format: date
            example: 2026-12-25
          description: Dates `YYYY-MM-DD` in `timezone`. Past dates are accepted and
            ignored; nothing purges them.
        closed:
          description: What happens to a call that arrives outside the hours or on a
            holiday.
          oneOf:
            - $ref: "#/components/schemas/InboundRouteClosedMessage"
            - $ref: "#/components/schemas/InboundRouteClosedRoute"
      example:
        timezone: America/Chicago
        weekly:
          mon:
            start: 09:00:00
            end: 17:00:00
          tue:
            start: 09:00:00
            end: 17:00:00
        holidays:
          - 2026-12-25
        closed:
          action: message
          message: We are closed. Please call back during business hours.
    InboundRouteClosedMessage:
      type: object
      additionalProperties: false
      required:
        - action
        - message
      description: >
        The caller hears the message once and the call ends. It is the tenant's
        own text (at most 2000 bytes, the same

        cap as an IVR menu prompt; the platform adds none of its own), rendered
        to audio when the hours are saved. The

        call is ANSWERED to play it, so it costs what an answered inbound call
        costs.
      properties:
        action:
          type: string
          enum:
            - message
        message:
          type: string
          maxLength: 2000
    InboundRouteClosedRoute:
      type: object
      additionalProperties: false
      required:
        - action
        - dest_type
      description: >
        The call is treated as a call to this destination. It takes the ids its
        `dest_type` needs and nothing else:

        `agent` and `ivr` take `dest_ref`; `queue` takes `queue_id`; `ai_agent`
        takes `ai_agent_id` and `queue_id`. Each

        must be live in this account at write time (a retired queue, a retired
        menu, a draft or paused AI agent is

        `422`). It has no hours of its own and cannot be `optout` (`422`). A
        closed destination is not recorded.
      properties:
        action:
          type: string
          enum:
            - route
        dest_type:
          type: string
          enum:
            - agent
            - queue
            - ivr
            - ai_agent
        dest_ref:
          type: string
          format: uuid
        queue_id:
          type: string
          format: uuid
        ai_agent_id:
          type: string
          format: uuid
    TenantLoginPolicyInput:
      type: object
      additionalProperties: false
      description: >
        The body of `PUT /v1/tenant/login-policy` (ADR 227, core#1760). Every
        field

        is optional; an empty body writes `observe` with nothing to observe.
      properties:
        mode:
          type: string
          enum:
            - off
            - observe
            - enforce
          default: observe
          description: >
            `off` is a version too (switching off is writing, not deleting).

            `observe` never denies. `enforce` denies with `403
            login_policy_denied`.
        ip_allow:
          type: array
          maxItems: 64
          items:
            type: string
            example: 203.0.113.0/24
          description: >
            CIDR blocks or bare addresses, IPv4 and IPv6; the families never
            cross.

            Empty = no restriction by address. A malformed entry or a `/0` is
            `422`.
        windows:
          type: object
          additionalProperties: false
          properties:
            member:
              $ref: "#/components/schemas/CampaignWeeklyWindow"
            account_admin:
              $ref: "#/components/schemas/CampaignWeeklyWindow"
          description: >
            Sign-in hours per role, in the form of a campaign's weekly window (a
            day

            without an entry is closed, a window never crosses midnight). A role

            without an entry has no hours restriction.
        timezone:
          type:
            - string
            - "null"
          example: America/Chicago
          description: An IANA zone; required when `windows` is not empty, refused
            otherwise.
        reason:
          type:
            - string
            - "null"
          maxLength: 1000
    TenantLoginPolicyVersion:
      type: object
      required:
        - version
        - mode
        - ip_allow
        - windows
        - timezone
        - set_by
        - reason
        - inserted_at
      description: One published (append-only) version of the tenant's login policy.
      properties:
        version:
          type: integer
          minimum: 1
        mode:
          type: string
          enum:
            - off
            - observe
            - enforce
        ip_allow:
          type: array
          items:
            type: string
        windows:
          type: object
          properties:
            member:
              $ref: "#/components/schemas/CampaignWeeklyWindow"
            account_admin:
              $ref: "#/components/schemas/CampaignWeeklyWindow"
        timezone:
          type:
            - string
            - "null"
        set_by:
          type: string
          example: user:7c1b6a0e-0000-4000-8000-000000000001
        reason:
          type:
            - string
            - "null"
        inserted_at:
          type: string
          format: date-time
    SmsAutoReplyInput:
      type: object
      required:
        - body
        - language
      description: |
        A version of the out-of-hours SMS auto-reply (core#1777, ADR 228).
      properties:
        body:
          type: string
          minLength: 1
          maxLength: 1600
          description: >
            The text sent. It must name an opt-out word that inbound keyword
            handling

            honours and must not contain a prohibited term.
        language:
          type: string
          pattern: ^[a-z]{2}(-[A-Z]{2})?$
          example: es-MX
          description: A language tag, `en` or `es-MX` style.
        cooldown_seconds:
          type: integer
          minimum: 3600
          maximum: 2592000
          default: 86400
          description: At most one auto-reply per thread in this many seconds (1 hour to
            30 days).
    SmsAutoReply:
      type: object
      required:
        - body
        - language
        - cooldown_seconds
        - set_by
        - updated_at
      description: The version of the SMS auto-reply currently in force.
      properties:
        body:
          type: string
        language:
          type: string
        cooldown_seconds:
          type: integer
        set_by:
          type:
            - string
            - "null"
          format: uuid
          description: The dashboard user that saved it; `null` for an API key.
        updated_at:
          type: string
          format: date-time
    TransferDestinationLineType:
      type: string
      enum:
        - mobile
        - landline
        - voip
        - unknown
      default: unknown
      description: |
        What the tenant declares the line to be — the same values and default
        as a contact's `line_type` on import. The consent gate reads it for a
        transfer to the number: `landline` lifts the prior-express-consent
        requirement; `unknown`, `mobile` and `voip` keep it.
    TransferDestination:
      type: object
      required:
        - id
        - number
        - label
        - line_type
        - line_type_declared_at
        - line_type_declared_by
        - registered_at
        - registered_by
      properties:
        id:
          type: string
          format: uuid
          description: The registration; the id to retire it by.
        number:
          type: string
          description: Strict E.164.
        label:
          type: string
        line_type:
          $ref: "#/components/schemas/TransferDestinationLineType"
        line_type_declared_at:
          type: string
          format: date-time
          description: When the current `line_type` was declared — `registered_at` until a
            `PATCH` changes it.
        line_type_declared_by:
          type: string
          description: Who declared the current `line_type` (same shape as `registered_by`).
        registered_at:
          type: string
          format: date-time
          description: When it was registered; the anchor the RND is queried with for this
            number.
        registered_by:
          type: string
          description: The user or agent id of the credential that registered it, or
            `tenant-api-key`.
    SsoServiceProvider:
      type: object
      required:
        - entity_id
        - acs_url
        - slo_url
        - metadata_url
      properties:
        entity_id:
          type: string
          description: The tenant's SP entityID (give it to the IdP).
        acs_url:
          type: string
          description: Assertion Consumer Service, HTTP-POST binding.
        slo_url:
          type: string
          description: SingleLogoutService (HTTP-POST and HTTP-Redirect bindings), where
            the IdP sends its LogoutRequest.
        metadata_url:
          type: string
          description: The public SP metadata document.
    SsoIdpConfig:
      type: object
      required:
        - id
        - idp_entity_id
        - sso_url
        - slo_url
        - certificates
        - require_signed_assertion
        - require_signed_response
        - email_attribute
        - enabled
        - updated_by
        - created_at
        - updated_at
      properties:
        id:
          type: string
          format: uuid
        idp_entity_id:
          type: string
        sso_url:
          type: string
        slo_url:
          type:
            - string
            - "null"
        certificates:
          type: array
          items:
            type: object
            required:
              - sha256
              - not_before
              - not_after
            properties:
              sha256:
                type: string
                description: Lowercase hex SHA-256 of the certificate DER; what a signature is
                  pinned to.
              not_before:
                type:
                  - string
                  - "null"
                format: date-time
              not_after:
                type:
                  - string
                  - "null"
                format: date-time
        require_signed_assertion:
          type: boolean
        require_signed_response:
          type: boolean
        email_attribute:
          type:
            - string
            - "null"
        enabled:
          type: boolean
        updated_by:
          type: string
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    SsoIdpConfigEnvelope:
      type: object
      required:
        - sso_idp_config
        - service_provider
      properties:
        sso_idp_config:
          oneOf:
            - $ref: "#/components/schemas/SsoIdpConfig"
            - type: "null"
        service_provider:
          oneOf:
            - $ref: "#/components/schemas/SsoServiceProvider"
            - type: "null"
    SignInEvent:
      type: object
      required:
        - id
        - user_id
        - email_attempted
        - method
        - outcome
        - reason
        - ip
        - user_agent
        - session_id
        - occurred_at
      properties:
        id:
          type: string
          format: uuid
        user_id:
          oneOf:
            - type: string
              format: uuid
            - type: "null"
          description: The person the attempt is attributed to; `null` when the address
            matched nobody.
        email_attempted:
          oneOf:
            - type: string
              pattern: ^[0-9a-f]{64}$
            - type: "null"
          description: sha256 (hex) of the normalized address taken with a fixed purpose
            prefix (not the bare sha256 of the address); only when no person
            matched. Never the address.
        method:
          type: string
          enum:
            - password
            - mfa
            - sso
        outcome:
          type: string
          enum:
            - success
            - failure
        reason:
          oneOf:
            - type: string
              enum:
                - bad_password
                - unknown_user
                - disabled
                - suspended
                - sso_required
                - mfa_failed
                - sso_rejected
                - mfa_pending
                - ip_not_allowed
                - outside_hours
                - policy_unavailable
                - observed_ip_outside
                - observed_hours_outside
            - type: "null"
        ip:
          oneOf:
            - type: string
            - type: "null"
          description: The caller's address as the platform attributes it (trusted proxies
            only).
        user_agent:
          oneOf:
            - type: string
              maxLength: 256
            - type: "null"
        session_id:
          oneOf:
            - type: string
              format: uuid
            - type: "null"
          description: The session the grant minted; `null` on a refusal and on
            `mfa_pending`.
        occurred_at:
          type: string
          format: date-time
    SupportAccessEventKind:
      type: string
      enum:
        - grant_created
        - grant_revoked
        - session_opened
        - session_revoked
        - request
        - request_denied
    SupportAccessGrant:
      type: object
      required:
        - id
        - granted_by
        - reason
        - duration_minutes
        - created_at
        - expires_at
        - revoked_at
        - revoked_by
      properties:
        id:
          type: string
          format: uuid
        granted_by:
          type: string
          format: uuid
          description: The `account_admin` who granted it.
        reason:
          type: string
          minLength: 1
          maxLength: 500
        duration_minutes:
          type: integer
          minimum: 15
          maximum: 480
        created_at:
          type: string
          format: date-time
        expires_at:
          type: string
          format: date-time
          description: Always `created_at + duration_minutes`; it never moves.
        revoked_at:
          oneOf:
            - type: string
              format: date-time
            - type: "null"
        revoked_by:
          oneOf:
            - type: string
              format: uuid
            - type: "null"
    SupportAccessEvent:
      type: object
      required:
        - id
        - grant_id
        - session_id
        - event
        - actor_kind
        - actor_ref
        - method
        - route
        - resource_id
        - ip
        - occurred_at
      properties:
        id:
          type: string
          format: uuid
        grant_id:
          type: string
          format: uuid
        session_id:
          oneOf:
            - type: string
              format: uuid
            - type: "null"
          description: The support session; `null` on `grant_created` and `grant_revoked`.
        event:
          $ref: "#/components/schemas/SupportAccessEventKind"
        actor_kind:
          type: string
          enum:
            - customer_user
            - operator
        actor_ref:
          type: string
          description: The customer person's id, or `operator:<id> (<name>)` for platform
            staff.
        method:
          oneOf:
            - type: string
              enum:
                - GET
                - HEAD
            - type: "null"
          description: Only on `request` and `request_denied`.
        route:
          oneOf:
            - type: string
            - type: "null"
          description: The route PATTERN (for example `/v1/leads/:id`), never the path
            with values; only on `request` and `request_denied`.
        resource_id:
          oneOf:
            - type: string
            - type: "null"
          description: The id in the path, on `request` rows only.
        ip:
          oneOf:
            - type: string
            - type: "null"
        occurred_at:
          type: string
          format: date-time
    SsoIdpConfigEvent:
      type: object
      required:
        - id
        - action
        - actor
        - snapshot
        - occurred_at
      properties:
        id:
          type: string
          format: uuid
        action:
          type: string
          enum:
            - created
            - updated
            - enabled
            - disabled
            - deleted
        actor:
          type: string
        snapshot:
          type: object
          additionalProperties: true
        occurred_at:
          type: string
          format: date-time
    InboundIvrAction:
      type: string
      pattern: ^(hangup|optout|(queue|agent):[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$
      description: |
        A terminal action: `queue:<queue_id>` (an inbound queue of the
        tenant), `agent:<agent_id>` (an active agent of the tenant), `hangup`,
        or `optout` (the caller asks to leave the tenant's list). Ids are
        answered in lowercase.
    InboundIvrMenuNode:
      type: object
      additionalProperties: false
      required:
        - prompt
        - default
        - entries
      properties:
        prompt:
          type: string
          minLength: 1
          description: |
            What the caller hears on entering the node, in the tenant's words
            (at most 2000 bytes; blank is refused). Rendered to audio when the
            menu is published.
        default:
          allOf:
            - $ref: "#/components/schemas/InboundIvrAction"
          description: |
            MANDATORY. With no key pressed the prompt plays again and, the
            second time, this action runs — no menu is a dead end. Never
            `optout`: pressing nothing is not asking to leave the list.
        entries:
          type: object
          minProperties: 1
          description: One DTMF key (`0`-`9`, `*`, `#`) → a terminal action or a submenu.
          propertyNames:
            pattern: ^[0-9*#]$
          additionalProperties:
            oneOf:
              - type: object
                additionalProperties: false
                required:
                  - action
                properties:
                  action:
                    $ref: "#/components/schemas/InboundIvrAction"
              - type: object
                additionalProperties: false
                required:
                  - submenu
                properties:
                  submenu:
                    $ref: "#/components/schemas/InboundIvrMenuNode"
    InboundIvrMenu:
      type: object
      required:
        - id
        - status
        - version
        - name
        - menu
        - published_at
        - published_by
        - retired_at
        - retired_by
      properties:
        id:
          type: string
          format: uuid
          description: The menu; stable across its versions.
        status:
          type: string
          enum:
            - published
            - retired
        version:
          type: integer
          minimum: 1
          description: The current version, or the one the menu was retired at.
        name:
          type: string
        menu:
          $ref: "#/components/schemas/InboundIvrMenuNode"
        published_at:
          type: string
          format: date-time
          description: When this version was published.
        published_by:
          type: string
          description: The user or agent id of the credential that published this version,
            or `tenant-api-key`.
        retired_at:
          type:
            - string
            - "null"
          format: date-time
        retired_by:
          type:
            - string
            - "null"
    InboundQueue:
      type: object
      required:
        - id
        - name
        - status
        - overflow_max_wait_ms
        - overflow_queue_id
        - callback_offer_after_ms
        - active_route_count
        - created_at
        - retired_at
        - retired_by
      properties:
        id:
          type: string
          format: uuid
        name:
          type:
            - string
            - "null"
          description: "`null` only for a queue created before names existed (core#1347)."
        status:
          type: string
          enum:
            - active
            - retired
        overflow_max_wait_ms:
          type:
            - integer
            - "null"
          minimum: 1
          description: The wait after which a call moves to `overflow_queue_id`; `null` =
            no overflow.
        overflow_queue_id:
          type:
            - string
            - "null"
          format: uuid
        callback_offer_after_ms:
          type:
            - integer
            - "null"
          minimum: 1
          description: The MEASURED wait, in milliseconds, after which a caller of this
            queue would be offered a callback; `null` = this queue offers none.
            Alone it offers nothing, because the account's
            `inbound_callback_offer_enabled` flag has to be on too (core#1670).
        active_route_count:
          type: integer
          minimum: 0
          description: How many ACTIVE `queue` routes of the tenant name this queue.
        created_at:
          type: string
          format: date-time
        retired_at:
          type:
            - string
            - "null"
          format: date-time
        retired_by:
          type:
            - string
            - "null"
          description: The user or agent id of the credential that retired it, or
            `tenant-api-key`.
        required_skills:
          type: array
          items:
            type: string
          description: The skills an agent needs to be offered this queue's calls
            (sorted); `[]` = demands nothing. Set with `PUT
            /v1/inbound-queues/{id}/skills`.
        warnings:
          type: array
          items:
            type: string
            enum:
              - required_skills_without_overflow
          description: "`required_skills_without_overflow`: the queue demands skills and
            has no overflow, so a call nobody qualifies for waits with no way
            out (ADR #216 D6)."
    AgentSkills:
      type: object
      required:
        - agent_id
        - skills
      properties:
        agent_id:
          type: string
          format: uuid
        skills:
          type: array
          items:
            type: string
            pattern: ^[a-z0-9][a-z0-9_-]{0,39}$
    InboundVipLists:
      type: object
      required:
        - vip_lists
      properties:
        vip_lists:
          type: array
          items:
            type: object
            required:
              - list_id
              - name
              - designated_at
            properties:
              list_id:
                type: string
                format: uuid
              name:
                type: string
              designated_at:
                type: string
                format: date-time
    InboundQueueSkills:
      type: object
      required:
        - queue_id
        - skills
        - warnings
      properties:
        queue_id:
          type: string
          format: uuid
        skills:
          type: array
          items:
            type: string
            pattern: ^[a-z0-9][a-z0-9_-]{0,39}$
        warnings:
          type: array
          items:
            type: string
            enum:
              - required_skills_without_overflow
    InboundQueueLive:
      type: object
      required:
        - queue_id
        - name
        - depth
        - oldest_wait_ms
        - vip_depth
        - vip_oldest_wait_ms
        - ready_matching
        - wait_bound_ms
      properties:
        queue_id:
          type: string
          format: uuid
        name:
          type:
            - string
            - "null"
        depth:
          type:
            - integer
            - "null"
          minimum: 0
          description: Calls waiting now (admissions with no terminal, inside
            `wait_bound_ms`). `null` when the tenant's queue stream is OFF —
            unknown, never `0`.
        oldest_wait_ms:
          type:
            - integer
            - "null"
          minimum: 0
          description: How long the longest-waiting call has waited, by the wall clock.
            `null` when `depth` is `null` or `0`.
        vip_depth:
          type:
            - integer
            - "null"
          minimum: 0
          description: Of `depth`, the VIP calls (admissions sealed `vip`). `null` while
            the VIP is not active (`inbound_queue_vip_head_start_ms` at `0`, or
            inert) or the queue stream is OFF — never `0`; `0` means the VIP is
            active and none waits.
        vip_oldest_wait_ms:
          type:
            - integer
            - "null"
          minimum: 0
          description: How long the longest-waiting VIP call has waited, by the wall
            clock. `null` when `vip_depth` is `null` or `0`.
        ready_matching:
          type:
            - integer
            - "null"
          minimum: 0
          description: How many Ready agents meet THIS queue's skill requirement (all of
            them). `null` when the queue requires no skills, skill routing is
            OFF, the seat journal is OFF, or the skills could not be read —
            never `0` for "not measured". Counts Ready agents without
            discounting those already holding a live offer.
        wait_bound_ms:
          type: integer
          minimum: 1
          description: The age past which an admission without a terminal is read as
            orphaned and left out. The queue's `overflow_max_wait_ms` when set;
            otherwise 3600000 (one hour).
    InboundQueueCapability:
      type: object
      required:
        - flag
        - enabled
        - switches
      properties:
        flag:
          type: string
          enum:
            - inbound_queue_stream_enabled
          description: The queue's own switch, the one turned on to turn the queue on.
        enabled:
          type: boolean
          description: Whether the inbound queue admits calls for this tenant — `true`
            only when BOTH `switches` are ON (core#1354). `false` is the
            default; this API does not change it.
        switches:
          type: object
          description: Each switch the queue needs, resolved for the tenant. With the
            queue ON and the seat journal OFF a call to a `queue` route is
            refused unanswered, because no agent could ever be offered it.
          required:
            - inbound_queue_stream_enabled
            - agent_session_journal_enabled
          properties:
            inbound_queue_stream_enabled:
              type: boolean
            agent_session_journal_enabled:
              type: boolean
    InboundIvrMenuFact:
      type: object
      required:
        - event
        - version
        - occurred_at
        - actor
      properties:
        event:
          type: string
          enum:
            - published
            - retired
        version:
          type: integer
          minimum: 1
          description: The version published, or the version a `retired` fact retired.
        name:
          type: string
          description: Only on `published`.
        menu:
          allOf:
            - $ref: "#/components/schemas/InboundIvrMenuNode"
          description: Only on `published`.
        occurred_at:
          type: string
          format: date-time
        actor:
          type: string
    TransferTargets:
      type: object
      required:
        - agents
        - extensions
        - external_destinations
      properties:
        agents:
          type: array
          items:
            type: object
            additionalProperties: false
            required:
              - id
              - name
              - sip_extension
              - presence
            properties:
              id:
                type: string
                format: uuid
                description: The agent; a valid transfer `destination`.
              name:
                type: string
              sip_extension:
                type: string
              presence:
                type: string
                enum:
                  - offline
                  - available
                  - ringing
                  - on_call
                  - wrap_up
                  - paused
                description: The seat state now, as in the roster; informative only.
        extensions:
          type: array
          description: Roster extensions with no agent behind them. Always empty today.
          items:
            type: object
            additionalProperties: false
            required:
              - sip_extension
              - label
            properties:
              sip_extension:
                type: string
                description: A valid transfer `destination`.
              label:
                type: string
        external_destinations:
          type: array
          description: The current registered external destinations. Never carries the
            number.
          items:
            type: object
            additionalProperties: false
            required:
              - id
              - label
            properties:
              id:
                type: string
                format: uuid
                description: The registration; a valid blind-transfer `destination`, resolved by
                  core to its E.164.
              label:
                type: string
    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. Read-only for the tenant:
            only the platform admin sets it (core#2415)."
        status:
          type: string
          enum:
            - active
            - quarantine
            - retired
        verified_at:
          oneOf:
            - type: string
              format: date-time
            - type: "null"
          description: Proof of ownership (core#2415, F-03). `null` = self-declared, never
            proved; `house` DIDs are born verified.
        verification_method:
          oneOf:
            - type: string
              enum:
                - house
                - otp
                - admin
            - type: "null"
          description: How the ownership was proved; `null` while `verified_at` is `null`.
        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
    DidLaunchChecklist:
      type: object
      required:
        - did_id
        - e164
        - steps
        - complete
        - warmup_min_active_days
      properties:
        did_id:
          type: string
          format: uuid
        e164:
          type: string
        complete:
          type: boolean
          description: All four steps are `done`.
        warmup_min_active_days:
          type: integer
        steps:
          type: array
          items:
            type: object
            required:
              - step
              - source
              - state
              - evidence
            properties:
              step:
                type: string
                enum:
                  - registration
                  - cnam
                  - attestation
                  - warmup
              source:
                type: string
                enum:
                  - derived
                  - manual
                description: Where the state comes from; `manual` is the operator's word.
              state:
                type: string
                enum:
                  - done
                  - pending
              evidence:
                type: object
                description: >
                  `derived`: `{origin, ...}` with the values behind the state

                  (`dids.attestation` + `value`; `cdr` + `active_days`,
                  `required_days`,

                  `dials`). `manual`: the latest `{confirmed_at, note}`, empty
                  if never

                  confirmed.
                additionalProperties: true
    DidCarrierTicket:
      type: object
      required:
        - id
        - did_id
        - carrier
        - subject
        - reference
        - status
        - opened_at
        - updated_at
        - events
      properties:
        id:
          type: string
          format: uuid
        did_id:
          type: string
          format: uuid
        carrier:
          type: string
        subject:
          type: string
        reference:
          type: string
        status:
          type: string
          enum:
            - open
            - in_progress
            - resolved
            - closed
        opened_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        events:
          type: array
          description: The append-only thread, oldest first; the first has `kind` `opened`.
          items:
            type: object
            required:
              - id
              - kind
              - status
              - note
              - created_at
            properties:
              id:
                type: string
                format: uuid
              kind:
                type: string
                enum:
                  - opened
                  - update
              status:
                type: string
                enum:
                  - open
                  - in_progress
                  - resolved
                  - closed
              note:
                type: string
              created_at:
                type: string
                format: date-time
    SupportTicket:
      type: object
      required:
        - id
        - tenant_id
        - number
        - reference
        - subject
        - body
        - status
        - priority
        - level
        - first_response_due_at
        - created_at
        - updated_at
      properties:
        id:
          type: string
          format: uuid
        tenant_id:
          type: string
          format: uuid
          description: Account that owns the ticket; addresses the thread and reply routes
            under `/v1/admin/tenants/{id}/support/tickets`.
        number:
          type: integer
          description: Global ticket number.
        reference:
          type: string
          description: "`TKT-<number>`, quoted in the answer e-mail subject."
        subject:
          type: string
        body:
          type: string
        status:
          type: string
          enum:
            - open
            - answered
            - resolved
            - closed
        priority:
          type: string
          enum:
            - low
            - normal
            - high
            - urgent
        level:
          type: string
          enum:
            - standard
            - priority
          description: Support level of the plan when opened.
        request_id:
          type:
            - string
            - "null"
        contact_email:
          type:
            - string
            - "null"
        first_response_due_at:
          type: string
          format: date-time
        first_responded_at:
          type:
            - string
            - "null"
          format: date-time
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    SupportTicketDetail:
      allOf:
        - $ref: "#/components/schemas/SupportTicket"
        - type: object
          required:
            - messages
          properties:
            messages:
              type: array
              description: The append-only thread, oldest first.
              items:
                type: object
                required:
                  - id
                  - author
                  - body
                  - created_at
                properties:
                  id:
                    type: integer
                  author:
                    type: string
                    enum:
                      - tenant
                      - operator
                  body:
                    type: string
                  created_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
        allowed_destinations:
          $ref: "#/components/schemas/CarrierAllowedDestinations"
        max_concurrent_calls:
          $ref: "#/components/schemas/CarrierMaxConcurrentCalls"
        max_daily_attempts:
          $ref: "#/components/schemas/CarrierMaxDailyAttempts"
        allowed_dial_modes:
          $ref: "#/components/schemas/CarrierAllowedDialModes"
        srtp:
          $ref: "#/components/schemas/CarrierSrtp"
        status:
          type: string
          enum:
            - active
            - disabled
            - retired
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    TrunkHealth:
      type: object
      required:
        - carrier_id
        - state
        - reason
        - since
        - observed_at
        - fresh
        - signals
      properties:
        carrier_id:
          type: string
          format: uuid
        state:
          type: string
          enum:
            - up
            - down
            - unknown
        reason:
          type: string
          description: "Why the trunk is in `state`: `probe_active`, `probe_inactive`,
            `cdr_failure_ratio`, `cdr_success_ratio`, `no_fresh_samples`,
            `inconclusive` (from the last transition), or — computed by the read
            — `no_data` (no event yet), `stale` (last event older than 15
            minutes) and `carrier_inactive` (`disabled`/`retired`)."
        since:
          oneOf:
            - type: string
              format: date-time
            - type: "null"
          description: When the trunk entered `state`; `null` with no data or when inactive.
        observed_at:
          oneOf:
            - type: string
              format: date-time
            - type: "null"
          description: When the last event (confirmations included) was observed.
        fresh:
          type: boolean
          description: "`false` when the last event is older than 15 minutes, there is
            none, or the trunk is inactive."
        signals:
          oneOf:
            - $ref: "#/components/schemas/TrunkHealthSignals"
            - type: "null"
          description: What the last event saw; `null` with no event or when inactive.
    TrunkHealthSignals:
      type: object
      required:
        - probe
        - cdr
      properties:
        probe:
          oneOf:
            - $ref: "#/components/schemas/TrunkHealthProbe"
            - type: "null"
        cdr:
          oneOf:
            - $ref: "#/components/schemas/TrunkHealthCdr"
            - type: "null"
          description: "`null` when the CDR corroboration is off or could not be read."
    TrunkHealthProbe:
      type: object
      required:
        - replicas
        - latency_ms
      properties:
        replicas:
          type: object
          required:
            - up
            - down
            - fresh
          description: "The SIP edge replicas with a fresh reading: how many saw the
            endpoint up, how many down (`fresh` >= `up` + `down`; a replica that
            reads it neither is in `fresh` only)."
          properties:
            up:
              type: integer
              minimum: 0
            down:
              type: integer
              minimum: 0
            fresh:
              type: integer
              minimum: 0
        latency_ms:
          oneOf:
            - type: number
            - type: "null"
          description: Best average OPTIONS round trip among the replicas; `null` if none
            reports it.
    TrunkHealthCdr:
      type: object
      required:
        - window_s
        - attempts
        - failures
      description: Original BYOC call attempts on the trunk in the window; `failures`
        are 408/500/502/503/504 answers. Below 30 attempts the CDR contributes
        nothing to the state.
      properties:
        window_s:
          type: integer
        attempts:
          type: integer
          minimum: 0
        failures:
          type: integer
          minimum: 0
    TrunkHealthEvent:
      type: object
      required:
        - id
        - carrier_id
        - state
        - prev_state
        - reason
        - evidence
        - observed_at
      properties:
        id:
          type: string
          format: uuid
        carrier_id:
          type: string
          format: uuid
        state:
          type: string
          enum:
            - up
            - down
            - unknown
        prev_state:
          oneOf:
            - type: string
              enum:
                - up
                - down
                - unknown
            - type: "null"
          description: "`null` on the first event; equal to `state` on a `confirm` row."
        reason:
          type: string
          description: The evaluator's reason, or `confirm` for a freshness row.
        evidence:
          type: object
          description: What the evaluator saw (`probe`, `cdr`, `consecutive`).
          additionalProperties: true
        observed_at:
          type: string
          format: date-time
    CarrierAllowedDestinations:
      type: array
      maxItems: 100
      uniqueItems: true
      description: "The destination allowlist of a BYOC trunk (core#1281, ADR #172):
        the exact E.164 numbers it may dial. `[]` = no restriction. Stored
        encrypted under the trunk's scope key."
      items:
        type: string
        pattern: ^\+[1-9][0-9]{7,14}$
    CarrierMaxConcurrentCalls:
      oneOf:
        - type: integer
          minimum: 1
          maximum: 1000000
        - type: "null"
      description: "Contract limit of a BYOC trunk (core#2176): simultaneous calls it
        admits. A dial that finds this many live calls on the trunk is refused
        with `egress.carrier_channels_full.v1`. `null` = no limit."
    CarrierMaxDailyAttempts:
      oneOf:
        - type: integer
          minimum: 1
          maximum: 1000000
        - type: "null"
      description: "Contract limit of a BYOC trunk (core#2176): dial attempts per UTC
        day (00:00 to 24:00 UTC). A dial that finds the day spent is refused
        with `egress.daily_attempts_exhausted.v1`. `null` = no limit."
    CarrierAllowedDialModes:
      oneOf:
        - type: array
          minItems: 1
          uniqueItems: true
          items:
            type: string
            enum:
              - manual
              - preview
              - progressive
              - predictive
        - type: "null"
      description: "Contract limit of a BYOC trunk (core#2176): the dial modes it
        permits. A dial in another mode — agentless mass dial included — is
        refused with `egress.dial_mode_not_allowed.v1`. `null` = no restriction;
        an empty list is a **400**."
    CarrierSrtp:
      type: string
      enum:
        - off
        - offer
        - require
      default: off
      description: "SRTP policy of the trunk's egress leg (core#2333, SBC
        kamailio#189). `off` (default) is plain RTP, exactly as before. `offer`
        offers SRTP and accepts plain RTP in the answer; `require` refuses an
        answer that is not SRTP. `offer` and `require` need a TLS `sip_proxy`
        (`sips:` or `;transport=tls`) and a `sip_proxy` without an `srtp`
        parameter of its own: anything else is a **400**. Core writes
        `;srtp=<mode>` to the edge for `offer`/`require` only."
    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."
    MessagingBrand:
      type: object
      required:
        - id
        - legal_name
        - display_name
        - entity_type
        - status
        - registry
      properties:
        id:
          type: string
          format: uuid
        legal_name:
          type: string
        display_name:
          type: string
        entity_type:
          type: string
          enum:
            - private_profit
            - public_profit
            - non_profit
            - government
            - sole_proprietor
        ein_last4:
          type: string
        vertical:
          type: string
        website:
          type: string
        email:
          type: string
        phone:
          type: string
        street:
          type: string
        city:
          type: string
        state:
          type: string
        postal_code:
          type: string
        country:
          type: string
        vetting_requested:
          type: boolean
        status:
          type: string
          enum:
            - pending
            - approved
            - rejected
        rejection_reason:
          type:
            - string
            - "null"
        provider_brand_id:
          type: string
        registry:
          type: string
          enum:
            - mock
            - telnyx
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    MessagingCampaign:
      type: object
      required:
        - id
        - brand_id
        - use_case
        - status
        - registry
      properties:
        id:
          type: string
          format: uuid
        brand_id:
          type: string
          format: uuid
        use_case:
          type: string
        description:
          type: string
        message_flow:
          type: string
        sample_messages:
          type: array
          items:
            type: string
        help_message:
          type: string
        optin_keywords:
          type: string
        optout_keywords:
          type: string
        status:
          type: string
          enum:
            - pending
            - approved
            - rejected
        rejection_reason:
          type:
            - string
            - "null"
        provider_campaign_id:
          type: string
        monthly_fee_usd:
          type: string
          description: Decimal string; the provider's monthly fee, at cost.
        registry:
          type: string
          enum:
            - mock
            - telnyx
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    MessagingCampaignNumber:
      type: object
      required:
        - id
        - campaign_id
        - e164
      properties:
        id:
          type: string
          format: uuid
        campaign_id:
          type: string
          format: uuid
        e164:
          type: string
        created_at:
          type: string
          format: date-time
    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"
        default_from_e164:
          oneOf:
            - type: string
            - type: "null"
          description: Sender line used when `POST /v1/sms/messages` has no `from`; `null`
            = none (such a send is `422 from_required`).
        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
    EmailTemplateVariable:
      type: object
      required:
        - name
        - type
      additionalProperties: false
      properties:
        name:
          type: string
          pattern: ^[a-z][a-z0-9_]{0,31}$
        type:
          type: string
          enum:
            - string
            - number
            - date
        required:
          type: boolean
          default: true
          description: An optional variable that is not sent renders empty.
    EmailTemplateInput:
      type: object
      required:
        - name
        - subject
        - body
      properties:
        name:
          type: string
          pattern: ^[a-z0-9][a-z0-9_.-]{0,63}$
          description: Template family; an existing name opens its next version.
        subject:
          type: string
          minLength: 1
          maxLength: 200
          description: "Static text: no `{{}}` markers; it may not name the debt, an
            amount or a creditor."
        body:
          type: string
          minLength: 1
          maxLength: 20000
        variables:
          type: array
          maxItems: 20
          items:
            $ref: "#/components/schemas/EmailTemplateVariable"
        creditor_names:
          type: array
          maxItems: 50
          description: Creditor names the subject policy checks the subject against
            (trimmed, de-duplicated).
          items:
            type: string
            maxLength: 120
    EmailTemplate:
      type: object
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
        version:
          type: integer
          minimum: 1
        status:
          type: string
          enum:
            - draft
            - approved
            - retired
        subject:
          type: string
        body:
          type: string
        variables:
          type: array
          items:
            $ref: "#/components/schemas/EmailTemplateVariable"
        creditor_names:
          type: array
          items:
            type: string
        approved_by:
          oneOf:
            - type: string
              format: uuid
            - type: "null"
        approved_at:
          oneOf:
            - type: string
              format: date-time
            - type: "null"
        retired_at:
          oneOf:
            - type: string
              format: date-time
            - type: "null"
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    SmsTemplateVariable:
      type: object
      required:
        - name
        - type
      additionalProperties: false
      properties:
        name:
          type: string
          pattern: ^[a-z][a-z0-9_]{0,31}$
        type:
          type: string
          enum:
            - string
            - number
            - date
        required:
          type: boolean
          default: true
          description: An optional variable that is not sent renders empty.
    SmsTemplateInput:
      type: object
      required:
        - name
        - body
      properties:
        name:
          type: string
          pattern: ^[a-z0-9][a-z0-9_.-]{0,63}$
          description: Template family; an existing name opens its next version.
        body:
          type: string
          minLength: 1
          maxLength: 1600
        variables:
          type: array
          maxItems: 20
          items:
            $ref: "#/components/schemas/SmsTemplateVariable"
    SmsTemplate:
      type: object
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
        version:
          type: integer
          minimum: 1
        status:
          type: string
          enum:
            - draft
            - approved
            - retired
        body:
          type: string
        variables:
          type: array
          items:
            $ref: "#/components/schemas/SmsTemplateVariable"
        approved_by:
          oneOf:
            - type: string
              format: uuid
            - type: "null"
        approved_at:
          oneOf:
            - type: string
              format: date-time
            - type: "null"
        retired_at:
          oneOf:
            - type: string
              format: date-time
            - type: "null"
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    SmsTemplatePolicy:
      type: object
      required:
        - templates_only
      properties:
        templates_only:
          type: boolean
    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
        - disposition.event
        - carrier.health_changed
        - anomaly.detected
        - ai.drift.detected
        - port_request.ported
    WebhookDeliveryEventName:
      type: string
      description: "An event a delivery can carry: the closed catalog
        (`WebhookEventName`) plus the system event `webhook.test`, which only
        `POST /v1/webhooks/{id}/test` appends (nobody subscribes to it)."
      enum:
        - call.ended
        - call.answered
        - promise.recorded
        - sms.received
        - sms.optout
        - recording.available
        - disposition.event
        - carrier.health_changed
        - anomaly.detected
        - ai.drift.detected
        - port_request.ported
        - webhook.test
    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/WebhookDeliveryEventName"
        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; `carrier.health_changed`: the
            trunk health event; `anomaly.detected`: the `opened` anomaly row;
            `ai.drift.detected`: the `opened` drift row); `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
        livemode:
          type: boolean
          description: Present, `false`, only on the simulated events of a `dd_test_` key
            (core#2847).
        environment:
          type: string
          enum:
            - test
          description: Present only on the simulated events of a `dd_test_` key (core#2847).
        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"
            - $ref: "#/components/schemas/DispositionEventData"
            - $ref: "#/components/schemas/TrunkHealthEvent"
            - $ref: "#/components/schemas/AnomalyDetectedData"
            - $ref: "#/components/schemas/AiDriftDetectedData"
            - $ref: "#/components/schemas/PortRequest"
    PortDocument:
      type: object
      additionalProperties: false
      required:
        - content_type
        - content_base64
      properties:
        content_type:
          type: string
          enum:
            - application/pdf
            - image/png
            - image/jpeg
        content_base64:
          type: string
          description: Base64 of the file, at most 2 MiB decoded.
    PortDocumentRef:
      type: object
      required:
        - sha256
        - content_type
      properties:
        sha256:
          type: string
        content_type:
          type: string
    PortRequest:
      type: object
      description: A port-in request; also the `data` of the webhook `port_request.ported`.
      required:
        - id
        - numbers
        - account_holder
        - status
        - carrier
        - loa
        - invoice
        - created_at
        - updated_at
      properties:
        id:
          type: string
          format: uuid
        numbers:
          type: array
          items:
            type: string
        account_holder:
          type: string
        status:
          type: string
          enum:
            - pending
            - scheduled
            - rejected
            - completed
        reject_reason:
          type:
            - string
            - "null"
        foc_date:
          type:
            - string
            - "null"
          format: date
          description: Firm date set by the carrier.
        carrier:
          type: string
        loa:
          $ref: "#/components/schemas/PortDocumentRef"
        invoice:
          $ref: "#/components/schemas/PortDocumentRef"
        completed_at:
          type:
            - string
            - "null"
          format: date-time
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    AnomalyDetectedData:
      type: object
      description: "`data` of `anomaly.detected` (core#1984): the evidence of an
        insights anomaly the moment the evaluator opens it — the
        `InsightsAnomaly` of `GET /v1/insights/anomalies` without the fields
        that only make sense to a later reader (`resolution`, `read`,
        `acknowledged_at`). `state` is always `open`. Sent once per opened
        anomaly; the resolution is NOT announced."
      required:
        - id
        - campaign_id
        - metric
        - gate
        - rule
        - direction
        - state
        - window_start
        - window_end
        - observed
        - baseline_mean
        - baseline_stddev
        - stddev_floor
        - threshold
        - k
        - sample_n
        - baseline_n
        - series
        - opened_at
      properties:
        id:
          type: string
          format: uuid
        campaign_id:
          type:
            - string
            - "null"
          format: uuid
          description: "`null` for the whole-tenant series."
        metric:
          $ref: "#/components/schemas/InsightsMetric"
        gate:
          type:
            - string
            - "null"
        rule:
          type: string
        direction:
          type: string
          enum:
            - up
            - down
        state:
          type: string
          enum:
            - open
        window_start:
          type: string
          format: date-time
        window_end:
          type: string
          format: date-time
        observed:
          type: number
        baseline_mean:
          type: number
        baseline_stddev:
          type: number
        stddev_floor:
          type: number
        threshold:
          type: number
        k:
          type: number
        sample_n:
          type: integer
          minimum: 30
        baseline_n:
          type: integer
          minimum: 24
        series:
          type: array
          items:
            $ref: "#/components/schemas/InsightsSeriesPoint"
        opened_at:
          type: string
          format: date-time
    AiDriftMetric:
      type: string
      enum:
        - first_audio_p95_ms
        - handoff_rate
        - provider_error_rate
        - failed_rate
        - cost_per_call
      description: "The five series the drift evaluator watches, each by hourly bucket
        (UTC, by the hour the call closed): the p95 of the calls' first-audio
        latency in ms, the share of valid calls handed to a human, the share of
        measured calls with a provider error, the share of calls that failed,
        and the mean cost of the priced calls in micro-USD. Every one alerts
        upwards; an improvement never alerts."
    AiDriftSeriesPoint:
      type: object
      required:
        - t
        - sample
        - value
      properties:
        t:
          type: string
          format: date-time
          description: Start of the hourly bucket.
        sample:
          type: integer
          minimum: 0
          description: Calls in the bucket.
        numerator:
          type: number
          description: Only for the ratio metrics.
        denominator:
          type: number
          description: Only for the ratio metrics.
        value:
          type:
            - number
            - "null"
    AiDriftEvent:
      type: object
      description: "One voice-AI drift alert: its `opened` row IS the evidence.
        `scope` is `agent` (`ai_agent_id`, and its `variant_id`, `null` for the
        base agent) or `model` (every call that used `model`, with or without a
        variant: the provider's own drift). A `cleared` row that cites it makes
        `state` `cleared` and fills `resolution`. `rule` says what judged it:
        `zscore.v1` carries the baseline it was measured against; `budget.v1`
        (the p95 first audio above the latency budget) has no baseline and those
        fields are `null`."
      required:
        - id
        - scope
        - ai_agent_id
        - variant_id
        - model
        - metric
        - rule
        - direction
        - state
        - window_start
        - window_end
        - observed
        - baseline_mean
        - baseline_stddev
        - stddev_floor
        - threshold
        - k
        - sample_n
        - baseline_n
        - series
        - opened_at
        - resolution
      properties:
        id:
          type: string
          format: uuid
        scope:
          type: string
          enum:
            - agent
            - model
        ai_agent_id:
          type:
            - string
            - "null"
          format: uuid
          description: "`null` in the `model` scope."
        variant_id:
          type:
            - string
            - "null"
          format: uuid
          description: "`null` for the base agent and in the `model` scope."
        model:
          type:
            - string
            - "null"
          description: Only in the `model` scope.
        metric:
          $ref: "#/components/schemas/AiDriftMetric"
        rule:
          type: string
          enum:
            - zscore.v1
            - budget.v1
        direction:
          type: string
          enum:
            - up
            - down
          description: The harmful direction that was crossed.
        state:
          type: string
          enum:
            - open
            - cleared
        window_start:
          type: string
          format: date-time
          description: The hourly bucket that crossed the threshold (inclusive).
        window_end:
          type: string
          format: date-time
          description: The same bucket (exclusive).
        observed:
          type: number
          description: The bucket value.
        baseline_mean:
          type:
            - number
            - "null"
          description: "`null` for `budget.v1`."
        baseline_stddev:
          type:
            - number
            - "null"
          description: Sample standard deviation of the baseline buckets; `null` for
            `budget.v1`.
        stddev_floor:
          type:
            - number
            - "null"
          description: The floor applied to the deviation; `null` for `budget.v1`.
        threshold:
          type: number
          description: "`zscore.v1`: `baseline_mean + k·max(baseline_stddev,
            stddev_floor)`; `budget.v1`: the latency budget in ms."
        k:
          type:
            - number
            - "null"
          description: The sensitivity of `zscore.v1` (3.0); `null` for `budget.v1`.
        sample_n:
          type: integer
          minimum: 30
          description: Calls in the bucket.
        baseline_n:
          type:
            - integer
            - "null"
          minimum: 24
          description: Baseline buckets used; `null` for `budget.v1`.
        series:
          type: array
          description: The buckets the rule looked at followed by the bucket that crossed,
            oldest first.
          items:
            $ref: "#/components/schemas/AiDriftSeriesPoint"
        opened_at:
          type: string
          format: date-time
        resolution:
          type:
            - object
            - "null"
          description: The row that cleared it; `null` while open.
          required:
            - id
            - window_start
            - window_end
            - observed
            - cleared_at
          properties:
            id:
              type: string
              format: uuid
            window_start:
              type: string
              format: date-time
            window_end:
              type: string
              format: date-time
            observed:
              type: number
            cleared_at:
              type: string
              format: date-time
    AiDriftCoverage:
      type: object
      description: 'What the evaluator can say of one series right now, from its last
        hourly bucket of the last 7 days: `ok` (evaluable),
        `insufficient_sample` (fewer than `min_sample` calls in the bucket) or
        `insufficient_baseline` (fewer than `min_baseline` buckets of the
        previous 7 days with enough calls). A series that is not `ok` is not
        being judged: an empty `drift` list for it is "not enough data yet",
        never "all fine".'
      required:
        - scope
        - ai_agent_id
        - variant_id
        - model
        - metric
        - status
        - window_start
        - sample_n
        - baseline_n
        - min_sample
        - min_baseline
      properties:
        scope:
          type: string
          enum:
            - agent
            - model
        ai_agent_id:
          type:
            - string
            - "null"
          format: uuid
        variant_id:
          type:
            - string
            - "null"
          format: uuid
        model:
          type:
            - string
            - "null"
        metric:
          $ref: "#/components/schemas/AiDriftMetric"
        status:
          type: string
          enum:
            - ok
            - insufficient_sample
            - insufficient_baseline
        window_start:
          type: string
          format: date-time
          description: The last hourly bucket with calls.
        sample_n:
          type: integer
          minimum: 0
        baseline_n:
          type: integer
          minimum: 0
        min_sample:
          type: integer
          example: 30
        min_baseline:
          type: integer
          example: 24
    AiDriftDetectedData:
      type: object
      description: "`data` of `ai.drift.detected` (core#2231): the evidence of a
        voice-AI drift alert the moment the evaluator opens it — the
        `AiDriftEvent` of `GET /v1/ai/agents/drift` without `resolution`.
        `state` is always `open`. Sent once per opened alert; clearing is NOT
        announced."
      required:
        - id
        - scope
        - ai_agent_id
        - variant_id
        - model
        - metric
        - rule
        - direction
        - state
        - window_start
        - window_end
        - observed
        - baseline_mean
        - baseline_stddev
        - stddev_floor
        - threshold
        - k
        - sample_n
        - baseline_n
        - series
        - opened_at
      properties:
        id:
          type: string
          format: uuid
        scope:
          type: string
          enum:
            - agent
            - model
        ai_agent_id:
          type:
            - string
            - "null"
          format: uuid
        variant_id:
          type:
            - string
            - "null"
          format: uuid
        model:
          type:
            - string
            - "null"
        metric:
          $ref: "#/components/schemas/AiDriftMetric"
        rule:
          type: string
          enum:
            - zscore.v1
            - budget.v1
        direction:
          type: string
          enum:
            - up
            - down
        state:
          type: string
          enum:
            - open
        window_start:
          type: string
          format: date-time
        window_end:
          type: string
          format: date-time
        observed:
          type: number
        baseline_mean:
          type:
            - number
            - "null"
        baseline_stddev:
          type:
            - number
            - "null"
        stddev_floor:
          type:
            - number
            - "null"
        threshold:
          type: number
        k:
          type:
            - number
            - "null"
        sample_n:
          type: integer
          minimum: 30
        baseline_n:
          type:
            - integer
            - "null"
          minimum: 24
        series:
          type: array
          items:
            $ref: "#/components/schemas/AiDriftSeriesPoint"
        opened_at:
          type: string
          format: date-time
    DispositionEventData:
      type: object
      description: "`data` of `disposition.event` (core#1505): the `emit_event` effect
        of a disposition rule, written to the outbox in the transaction that
        applies the disposition. One event per applied disposition and rule;
        `event_id` is a digest of (tenant, debt, disposition, the attempt's
        anchor, `name`), so a replay is announced once."
      required:
        - name
        - disposition
        - debt_id
        - campaign_id
      properties:
        name:
          type: string
          description: The event name the rule set in `params.name`.
        disposition:
          type: string
          description: The outcome code that triggered the rule.
        debt_id:
          type: string
          format: uuid
        campaign_id:
          oneOf:
            - type: string
              format: uuid
            - type: "null"
          description: The campaign of the attempt; `null` when it had none.
    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/WebhookDeliveryEventName"
        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 two no send produced: `manual_redelivery` (the operator asked
            for one via the redeliver endpoint) and `endpoint_circuit_open` (the
            endpoint's circuit breaker was open, so the delivery was put back
            for when it ends; it does not count as an attempt)."
        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"
        thread_id:
          oneOf:
            - type: string
              format: uuid
            - type: "null"
          description: Inbox thread of the row; null on rows written before threads existed.
        occurred_at:
          type: string
          format: date-time
        corrects_id:
          oneOf:
            - type: string
              format: uuid
            - type: "null"
        recorded_at:
          type: string
          format: date-time
    SmsThread:
      type: object
      description: One inbox thread - the ordered pair (consumer number, our line).
        The last message is the last ORIGINAL inside the window; the body is a
        40-char preview.
      required:
        - thread_id
        - tenant_id
        - consumer_number
        - line_number
        - last_message_id
        - last_direction
        - last_body_preview
        - last_message_at
        - unread
        - assigned_to
      properties:
        thread_id:
          type: string
          format: uuid
          description: Opaque identity of the thread; the only handle (no digest is ever
            returned).
        tenant_id:
          type: string
          format: uuid
        consumer_number:
          type: string
          description: The consumer's E.164 number, read off the last message.
        line_number:
          type: string
          description: Our line's E.164 number.
        last_message_id:
          type: string
          format: uuid
        last_direction:
          type: string
          enum:
            - outbound
            - inbound
        last_body_preview:
          oneOf:
            - type: string
            - type: "null"
        last_message_at:
          type: string
          format: date-time
        unread:
          type: boolean
          description: "`true` if the window holds an inbound message of the thread newer
            than the thread's last `read` event."
        assigned_to:
          oneOf:
            - type: string
              format: uuid
            - type: "null"
          description: User the thread was last assigned to (the `user_id` of its last
            assigned/unassigned event); null if never assigned or released.
    SmsThreadEvent:
      type: object
      description: One append-only event of an inbox thread, as recorded.
      required:
        - thread_id
        - kind
        - user_id
        - actor_user_id
        - occurred_at
      properties:
        thread_id:
          type: string
          format: uuid
        kind:
          type: string
          enum:
            - assigned
            - unassigned
            - read
        user_id:
          oneOf:
            - type: string
              format: uuid
            - type: "null"
          description: The assignee of an `assigned` event; null for `unassigned` and
            `read`.
        actor_user_id:
          type: string
          format: uuid
          description: The login that wrote the event.
        occurred_at:
          type: string
          format: date-time
    SmsThreadDetail:
      description: An inbox thread plus the debts that carry the consumer's number.
      allOf:
        - $ref: "#/components/schemas/SmsThread"
        - type: object
          required:
            - debt_ids
          properties:
            debt_ids:
              type: array
              maxItems: 200
              items:
                type: string
                format: uuid
              description: Debts of the tenant whose contact has the consumer's number (at
                most 200, ordered by id). Empty = none; never a filter.
    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"
        thread_id:
          oneOf:
            - type: string
              format: uuid
            - type: "null"
          description: Inbox thread of the message; null on messages written before
            threads existed.
        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"
    EmailAddress:
      type: object
      description: A debtor email address with its provenance. `sendable` is computed
        when read, never stored.
      required:
        - id
        - contact_id
        - address
        - provenance
        - sendable
        - ineligible_reason
      properties:
        id:
          type: string
          format: uuid
        contact_id:
          type: string
          format: uuid
        address:
          oneOf:
            - type: string
            - type: "null"
          description: "`null` once erased by a privacy request (core#2636)."
        provenance:
          type: string
          enum:
            - consumer_replied
            - consumer_consent
            - creditor_notice
            - prior_collector
            - imported_unverified
        provenance_evidence:
          type: string
        notice_sent_at:
          oneOf:
            - type: string
              format: date-time
            - type: "null"
        obtained_at:
          type: string
          format: date-time
        domain_public:
          oneOf:
            - type: boolean
            - type: "null"
          description: Derived from the address domain; never client input.
        sendable:
          type: boolean
        ineligible_reason:
          oneOf:
            - type: string
              enum:
                - provenance_not_sendable
                - evidence_missing
                - notice_date_missing
                - notice_period_open
                - domain_not_public
                - suppressed
                - suppression_unreadable
                - address_erased
            - type: "null"
        created_at:
          type: string
          format: date-time
    EmailSuppression:
      type: object
      description: One row of the append-only suppression ledger of an address (no
        digest, no address).
      properties:
        id:
          type: string
          format: uuid
        seq:
          type: integer
          description: Position in the address's ledger; the highest wins.
        action:
          type: string
          enum:
            - suppress
            - lift
        reason:
          oneOf:
            - type: string
              enum:
                - unsubscribe
                - hard_bounce
                - complaint
                - third_party
                - cease_communication
            - type: "null"
          description: Set on `suppress` rows.
        provenance:
          oneOf:
            - type: string
              enum:
                - consumer_consent
                - consumer_replied
            - type: "null"
          description: Set on `lift` rows.
        evidence:
          type: string
        occurred_at:
          type: string
          format: date-time
        recorded_at:
          type: string
          format: date-time
    EmailProvider:
      type: object
      description: "A registered email provider. The `smtp` adapter does NOT receive
        deferred bounces (NDRs) or complaints: a relay that is not ours does not
        speak the signed event webhook, and the bounce goes to the mailbox of
        `from_address`. The only bounce it sees is the relay's immediate refusal
        of the recipient at submission (`550`/`551`/`553` with `5.1.1`, `5.1.2`,
        `5.1.3` or `5.1.6`), which suppresses the address as `hard_bounce`. If
        the relay offers bounce/complaint events, point them at the event
        webhook (`has_webhook_secret`)."
      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 provider inherited by every sede."
        scope:
          type: string
          enum:
            - empresa
            - sede
          description: Derived from `tenant_id`.
        adapter:
          type: string
          enum:
            - smtp
            - ses
            - log
        region:
          type: string
          enum:
            - us
            - mx
            - eu
        name:
          type: string
        from_address:
          type: string
        host:
          type: string
        port:
          type: integer
        username:
          type: string
        has_secret:
          type: boolean
          description: The SMTP secret NEVER rides the wire — this is the only signal a
            credential is stored.
        has_webhook_secret:
          type: boolean
          description: The event-signing key NEVER rides the wire — this is the only
            signal one is stored.
        status:
          type: string
          enum:
            - active
            - disabled
            - retired
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    DebtMessage:
      type: object
      description: One outbound message sent to a debt, on either channel. Body is a
        40-char preview.
      properties:
        channel:
          type: string
          enum:
            - sms
            - email
        id:
          type: string
          format: uuid
        status:
          type: string
          description: Current status (latest transition of the original).
        recipient:
          oneOf:
            - type: string
            - type: "null"
        subject:
          oneOf:
            - type: string
            - type: "null"
        body_preview:
          oneOf:
            - type: string
            - type: "null"
        provider_id:
          oneOf:
            - type: string
              format: uuid
            - type: "null"
        campaign_id:
          oneOf:
            - type: string
              format: uuid
            - type: "null"
        debt_id:
          type: string
          format: uuid
        occurred_at:
          type: string
          format: date-time
        last_event_at:
          type: string
          format: date-time
    DebtMessagePage:
      type: object
      properties:
        messages:
          type: array
          items:
            $ref: "#/components/schemas/DebtMessage"
        next_cursor:
          oneOf:
            - type: string
            - type: "null"
    EmailLeadSkips:
      type: object
      required:
        - campaign_id
        - total_leads
        - reasons
        - samples
      properties:
        campaign_id:
          type: string
          format: uuid
        total_leads:
          type: integer
          minimum: 0
          description: Distinct debts discarded for ANY reason (a debt with two reasons
            counts once).
        reasons:
          type: array
          description: Every reason of the closed vocabulary, zeros included, in
            vocabulary order.
          items:
            $ref: "#/components/schemas/EmailLeadSkipReasonCount"
        samples:
          type: array
          items:
            $ref: "#/components/schemas/EmailLeadSkipSample"
    EmailLeadSkipReasonCount:
      type: object
      required:
        - reason
        - leads
        - days
        - first_at
        - last_at
      properties:
        reason:
          type: string
          enum:
            - debt_not_found
            - debt_not_dialable
            - no_sendable_address
            - email_address_invalid
            - email_debt_unresolved
            - throttled_plan_inactive
            - throttled_rate_limit
            - throttled_provider_open
            - throttled_spend_cap
            - throttled_no_rate
            - throttled_synthetic_tenant
        leads:
          type: integer
          minimum: 0
          description: Distinct debts discarded for this reason.
        days:
          type: integer
          minimum: 0
          description: UTC days with at least one row for this reason.
        first_at:
          type:
            - string
            - "null"
          format: date-time
        last_at:
          type:
            - string
            - "null"
          format: date-time
    EmailLeadSkipSample:
      type: object
      required:
        - debt_id
        - reason
        - occurred_at
      properties:
        debt_id:
          type: string
          format: uuid
        reason:
          type: string
          enum:
            - debt_not_found
            - debt_not_dialable
            - no_sendable_address
            - email_address_invalid
            - email_debt_unresolved
            - throttled_plan_inactive
            - throttled_rate_limit
            - throttled_provider_open
            - throttled_spend_cap
            - throttled_no_rate
            - throttled_synthetic_tenant
        occurred_at:
          type: string
          format: date-time
    EmailMessage:
      type: object
      description: One email 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
            - deferred
            - bounced
            - complained
            - failed
            - received
        from_address:
          oneOf:
            - type: string
            - type: "null"
        to_address:
          type: string
        subject:
          oneOf:
            - type: string
            - type: "null"
        body_preview:
          oneOf:
            - type: string
            - type: "null"
        template_version_id:
          oneOf:
            - type: string
              format: uuid
            - type: "null"
        region:
          type: string
        client_ref:
          type: string
        provider_id:
          oneOf:
            - type: string
              format: uuid
            - type: "null"
        provider_message_id:
          type: string
        campaign_id:
          oneOf:
            - type: string
              format: uuid
            - type: "null"
        debt_id:
          oneOf:
            - type: string
              format: uuid
            - type: "null"
        contact_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
    EmailMessageView:
      type: object
      description: The COLLAPSED single-email view — current status, per-status
        timestamps, frozen compliance snapshot, FULL subject/body, ordered
        transitions.
      properties:
        id:
          type: string
          format: uuid
        direction:
          type: string
          enum:
            - outbound
            - inbound
        status:
          type: string
          enum:
            - queued
            - sent
            - delivered
            - deferred
            - bounced
            - complained
            - failed
            - received
        from_address:
          oneOf:
            - type: string
            - type: "null"
        to_address:
          type: string
        subject:
          oneOf:
            - type: string
            - type: "null"
        body:
          oneOf:
            - type: string
            - type: "null"
        template_version_id:
          oneOf:
            - type: string
              format: uuid
            - type: "null"
        region:
          type: string
        client_ref:
          type: string
        provider_id:
          oneOf:
            - type: string
              format: uuid
            - type: "null"
        provider_message_id:
          type: string
        campaign_id:
          oneOf:
            - type: string
              format: uuid
            - type: "null"
        debt_id:
          oneOf:
            - type: string
              format: uuid
            - type: "null"
        contact_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/EmailMessage"
    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
            - optout
            - ai_agent
            - webhook
          description: "`ivr` runs the inbound IVR menu `dest_ref` names (core#1319). A
            route saved before core#1311 without a menu is still refused at
            connect with `CALL_REJECTED`. `optout` is the opt-out number
            (core#1316): the calling number joins the tenant's own do-not-call
            list at connect and FreeSWITCH confirms and hangs up; such a route
            always reads `dest_ref: null`, `recording: false`, `ai_enabled:
            false`. `agent` bridges straight to the registered agent; `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 and its
            `agent_session_journal_enabled` ON too (core#1354: with the seat
            journal OFF the call is refused unanswered, since no agent could be
            offered it), `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.
            `ai_agent` names a voice AI agent of the account (core#1547, ADR
            #200): `ai_agent_id` (a `live` agent of this account) and `queue_id`
            (the active queue the call falls back to) are REQUIRED, or the write
            is `400 invalid`; `ai_enabled` reads `true` and `recording` `false`,
            both derived from the type, not chosen. The admission is decided
            before the call is answered (core#1548, ADR #200 decision 3): with
            the tenant's `inbound_ai_enabled` flag off (the default) or any of
            its checks failing, the call is treated as a call to the `queue_id`
            queue and is not answered by the AI; the reason is recorded in the
            call's CDR evidence (`compliance_snapshot.inbound_ai`). Nothing
            hands a call to the agent yet (the fork is a later delivery of ADR
            #200), so a call to this number is not answered by the AI. `webhook`
            hands the call to the developer's webhook (core#2864, ADR #356):
            `webhook_url` is REQUIRED (https on 443, a host that resolves only
            to public addresses, or `400 invalid`) and the route is bare —
            `recording: true`, `dest_ref`, `queue_id`, `ai_agent_id` and
            `ai_enabled: true` in the same write are `400 invalid`; left out,
            `recording` is `false`. The call passes the same plan, spend and
            capacity verdicts as any inbound call BEFORE the webhook is ever
            contacted; the leg then waits in FreeSWITCH's `dd_ivr_hold`, which
            answers it, and from the answer core POSTs each step to
            `webhook_url` (`call.started`, then `gather.completed` or
            `gather.timeout`) signed with `X-DD-Signature:
            t=<unix>,v1=<hex(HMAC-SHA256(secret, \"<t>.<body>\"))>` — the same
            construction as an outbound-webhook endpoint's. The webhook answers
            `200` with `{\"actions\": [...]}`: 1 to 8 actions among `say`
            (`text`, up to 500 bytes, rendered to audio by the platform),
            `gather` (`num_digits` 1-10, `timeout_seconds` 1-30,
            `finish_on_key`; ends the list, its result is the NEXT request) and
            the terminal verbs `queue` (`queue_id`), `agent` (`agent_id`) and
            `hangup`; the list must end in a `gather` or a terminal verb.
            Fail-closed: no answer in 3 seconds, a non-2xx, a body that is not
            valid actions, a destination that is not this account's, or more
            than 20 requests per call hang the call up (`NORMAL_CLEARING`);
            nothing is ever routed to a human or a queue the webhook did not
            choose. Every step is recorded in an append-only, signed stream. A
            route saved without its URL is refused at connect
            (`refused_webhook_unavailable`). A webhook route does not record."
        dest_ref:
          oneOf:
            - type: string
            - type: "null"
        queue_id:
          type:
            - string
            - "null"
          format: uuid
          description: The queue of a `queue` route (core#1347); `null` on any other type,
            and on a `queue` route saved without one.
        ai_agent_id:
          type:
            - string
            - "null"
          format: uuid
          description: The AI agent of an `ai_agent` route (core#1547); `null` on any
            other type.
        hours:
          oneOf:
            - $ref: "#/components/schemas/InboundRouteHours"
            - type: "null"
          description: The route's hours (core#2530), or `null` when it answers at all
            hours. A `closed` message reads back as the text that was sent.
        webhook_url:
          type:
            - string
            - "null"
          description: "The developer's webhook of a `webhook` route (core#2864, ADR
            #356); `null` on any other type."
        webhook_secret_hint:
          type:
            - string
            - "null"
          description: The last 4 characters of a `webhook` route's signing secret; `null`
            on any other type. The secret itself is never readable.
        webhook_secret:
          type: string
          description: "The signing secret of a `webhook` route (`whsec_…`), present ONLY
            in the response of the write that minted it (create, a write that
            moves the route to `webhook`, a write that names `webhook_url`);
            never on a read or a list. Store it: it cannot be shown again."
        recording:
          type: boolean
        ai_enabled:
          type: boolean
          description: "`true` if and only if `dest_type` is `ai_agent` (derived, ADR
            #200); `false` on every other type. A write that names the opposite
            is `400` (ADR #148). Routes that held `true` before the flag was
            refused were turned off by a data migration."
        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)."
    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. On write it must be a `voice_id` of
            `GET /v1/ai/voices` (`422` otherwise); an agent stored before the
            catalog may still carry an id outside it.
        pace:
          type: number
          minimum: 0
          maximum: 1
          description: 0 = slow … 1 = fast (0.5 ≈ natural).
    AiVoiceCatalogEntry:
      type: object
      required:
        - voice_id
        - provider
        - language
        - label
      properties:
        voice_id:
          type: string
          description: The provider's voice identifier — what an agent stores in
            `voice.voice_id`.
        provider:
          type: string
          description: Who synthesizes it (e.g. `cartesia`).
        language:
          type: string
          description: Primary BCP-47 subtag the voice speaks (e.g. `en`).
        label:
          type: string
          description: The name an operator sees.
    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
          description: "`card_on_file` is refused with `422` while the tenant's
            `ai_take_payments_enabled` flag is off (the default): nothing
            collects money yet. An agent already stored with `card_on_file` is
            served to the voice runtime as `none`."
        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, validated per trigger (`400 invalid` on an unknown
            or mistyped key): `keywords` -> `{keywords:[...]}`;
            `negative_sentiment` -> `{seconds:20}` (integer >= 1);
            `payment_over` -> `{threshold_cents:50000}` (integer >= 1, the one
            spelling the voice engine reads). `human_requested` ->
            `{keywords:[...]}` (optional; the engine falls back to its default
            phrases when absent)."
          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"
        model:
          $ref: "#/components/schemas/AiAgentModel"
        prompt_style:
          $ref: "#/components/schemas/AiAgentPromptStyle"
        llm:
          $ref: "#/components/schemas/AiAgentLlm"
        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"
        model:
          $ref: "#/components/schemas/AiAgentModel"
        prompt_style:
          $ref: "#/components/schemas/AiAgentPromptStyle"
        llm:
          $ref: "#/components/schemas/AiAgentLlm"
    AiAgentModel:
      type:
        - string
        - "null"
      description: >
        The LLM the agent talks with; `null` = the runtime's default. On write
        it must be in the

        model catalog (`422` otherwise), the same list the A/B variants' `model`
        override uses.
    AiAgentPromptStyle:
      type:
        - string
        - "null"
      minLength: 1
      maxLength: 600
      description: >
        A single line (no line break or control character) appended to the
        composed prompt; it

        replaces nothing of it. `null` or `""` on write clears it; `422` when
        only whitespace, longer

        than 600 characters or multi-line.
    AiAgentLlm:
      type: object
      description: >
        LLM knobs; `{}` = the runtime's defaults, and sending `{}` clears what
        the agent stored.

        Otherwise only `reasoning_effort` / `prompt_form`, `422` on any other
        key or value.

        A variant's `llm` override wins over these while an experiment runs.
      additionalProperties: false
      properties:
        reasoning_effort:
          type: string
          enum:
            - low
            - medium
            - high
        prompt_form:
          type: string
          enum:
            - compact
            - full
    AiTestRequest:
      type: object
      required:
        - message
      properties:
        session_id:
          type: string
          format: uuid
          description: Omit to start a new sandbox thread; echo to continue one. At most
            128 bytes (`400` above).
        message:
          type: string
          description: At most 4 000 bytes (`400` above).
    AiTestReply:
      type: object
      required:
        - session_id
        - reply
      properties:
        session_id:
          type: string
          format: uuid
        reply:
          type: string
    AiVoicePreview:
      type: object
      required:
        - url
        - voice_id
        - language
        - text
        - cached
      properties:
        url:
          type: string
          description: Signed, short-lived URL of the audio; sign-on-read, never stored.
        voice_id:
          type: string
          description: The catalogued voice that read the sentence.
        language:
          type: string
          description: Primary BCP-47 subtag of the voice (`en` or `es`) — it picks the
            sentence.
        text:
          type: string
          description: The fixed sentence that was read.
        cached:
          type: boolean
          description: "`true` when the audio already existed (nothing was rendered for
            this request)."
    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.

        `handoff_failed`: the consumer asked for a human and the handoff was NOT

        ordered; `detail` carries the reason when the runtime knows it. It is

        neither `resolved` nor `handoff` (core#1115).
      required:
        - type
      properties:
        type:
          type: string
          enum:
            - resolved
            - ptp
            - payment_taken
            - refused
            - dispute
            - callback
            - voicemail
            - handoff
            - handoff_failed
            - 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
    AcademyCompletion:
      type: object
      description: One recorded Academy milestone. Immutable once written.
      required:
        - id
        - agent_id
        - kind
        - item_id
        - occurred_at
      properties:
        id:
          type: string
          format: uuid
        agent_id:
          type: string
          format: uuid
        kind:
          type: string
          enum:
            - lesson
            - badge
        item_id:
          type: string
          example: ch1.dashboard
        occurred_at:
          type: string
          format: date-time
          description: Stamped by the server, never by the caller.
    AcademyProgress:
      type: object
      description: |
        The DERIVED state of one agent's journey. Mirrors the shape the Academy
        already renders from in the browser (`lessonsDone` is the source of
        truth; chapter and level are computed at render, not stored).
      required:
        - agent_id
        - lessons_done
        - badges
        - updated_at
      properties:
        agent_id:
          type: string
          format: uuid
        lessons_done:
          type: array
          description: Lesson ids in completion order.
          items:
            type: string
        badges:
          type: object
          description: Badge id -> the instant it was earned.
          additionalProperties:
            type: string
            format: date-time
        updated_at:
          type:
            - string
            - "null"
          format: date-time
          description: The most recent milestone, or `null` when the agent has none yet.
    AcademyTranscriptRow:
      type: object
      required:
        - agent_id
        - agent_name
        - agent_email
        - agent_role
        - kind
        - item_id
        - occurred_at
      properties:
        agent_id:
          type: string
          format: uuid
        agent_name:
          type: string
        agent_email:
          type: string
        agent_role:
          type: string
          enum:
            - agent
            - supervisor
            - admin
          description: The agent's role TODAY (a join, not a snapshot) — someone who
            changed role has not un-completed their training.
        kind:
          type: string
          enum:
            - lesson
            - badge
        item_id:
          type: string
        occurred_at:
          type: string
          format: date-time
    AcademyTranscript:
      type: object
      required:
        - from
        - to
        - agent_id
        - total
        - rows
      properties:
        from:
          type: string
          format: date-time
        to:
          type: string
          format: date-time
        agent_id:
          type:
            - string
            - "null"
          format: uuid
          description: The agent the report was filtered to, or `null` for the whole tenant.
        total:
          type: integer
        rows:
          type: array
          items:
            $ref: "#/components/schemas/AcademyTranscriptRow"
    AiMetricsToday:
      type: object
      description: Aggregate for the current UTC day.
      properties:
        date:
          type: string
          format: date
        handled:
          type: integer
          description: AI-handled conversations today, settled or not.
        unsettled:
          type: integer
          minimum: 0
          description: "Conversations started today with no terminal outcome yet
            (`outcome_type` null: still in flight, or their close never reached
            core). Counted in `handled`, never in `containment_rate`."
        containment_rate:
          oneOf:
            - type: number
              minimum: 0
              maximum: 1
            - type: "null"
          description: >
            Share of today's settled conversations WITH A PERSON that the AI

            resolved on its own (0..1), rounded to 4 decimals (core#1121,

            ADR #147). Numerator: `outcome_type` in `resolved`, `ptp`,

            `payment_taken`, `callback`, `refused`. Denominator: the numerator

            plus `failed`, `handoff`, `handoff_failed` and `dispute`. In
            neither:

            `voicemail` (no person on the line; see `voicemail_drops`) and the

            `unsettled` conversations. A conversation handed to a human (the

            runtime reported `handoff.occurred`) is never contained, whatever

            its `outcome_type`: a `ptp` or `payment_taken` followed by a handoff

            counts in the denominator only (core#1131, ADR #149). `null` when

            `containment_reason` is set.
        containment_reason:
          oneOf:
            - type: string
              enum:
                - no_settled_conversations
            - type: "null"
          description: |
            Why `containment_rate` is `null`: `no_settled_conversations` (no
            conversation with a person settled today — the denominator is 0;
            absence, not a zero). `null` when `containment_rate` is a number.
        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
        handoffs_failed:
          type: object
          description: "Conversations settled `handoff_failed` today: a human was
            requested and the handoff was not ordered. Counted neither in
            `handoffs` nor in containment."
          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
